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
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:
24
crates/bds-release/Cargo.toml
Normal file
24
crates/bds-release/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "bds-release"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
apple-bundles.workspace = true
|
||||
apple-codesign.workspace = true
|
||||
apple-dmg.workspace = true
|
||||
flate2.workspace = true
|
||||
icns.workspace = true
|
||||
plist.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
simple-file-manifest.workspace = true
|
||||
tar.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
424
crates/bds-release/src/gitea.rs
Normal file
424
crates/bds-release/src/gitea.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use reqwest::{
|
||||
StatusCode,
|
||||
blocking::{Body, Client},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
env,
|
||||
error::Error,
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::Command,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Issue {
|
||||
number: u64,
|
||||
title: String,
|
||||
html_url: String,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
labels: Vec<Label>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Label {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
id: u64,
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
assets: Option<Vec<Asset>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ReleasePayload<'a> {
|
||||
tag_name: &'a str,
|
||||
target_commitish: &'a str,
|
||||
name: &'a str,
|
||||
body: String,
|
||||
draft: bool,
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
struct Gitea {
|
||||
client: Client,
|
||||
api_url: String,
|
||||
repository: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Gitea {
|
||||
fn from_environment() -> Result<Self, Box<dyn Error>> {
|
||||
Ok(Self {
|
||||
client: Client::builder().build()?,
|
||||
api_url: required_env("GITEA_API_URL")?
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
repository: required_env("GITEA_REPOSITORY")?,
|
||||
token: required_env("GITEA_TOKEN")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.api_url)
|
||||
}
|
||||
|
||||
fn issue(&self, number: u64) -> Result<Issue, Box<dyn Error>> {
|
||||
Ok(self
|
||||
.client
|
||||
.get(self.url(&format!("/repos/{}/issues/{number}", self.repository)))
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()?
|
||||
.error_for_status()?
|
||||
.json()?)
|
||||
}
|
||||
|
||||
fn release_for_tag(&self, tag: &str) -> Result<Option<Release>, Box<dyn Error>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(self.url(&format!(
|
||||
"/repos/{}/releases/tags/{}",
|
||||
self.repository,
|
||||
percent_encode(tag)
|
||||
)))
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(response.error_for_status()?.json()?))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_release(&self, payload: &ReleasePayload<'_>) -> Result<Release, Box<dyn Error>> {
|
||||
Ok(self
|
||||
.client
|
||||
.post(self.url(&format!("/repos/{}/releases", self.repository)))
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(payload)
|
||||
.send()?
|
||||
.error_for_status()?
|
||||
.json()?)
|
||||
}
|
||||
|
||||
fn update_release(
|
||||
&self,
|
||||
release_id: u64,
|
||||
payload: &impl Serialize,
|
||||
) -> Result<Release, Box<dyn Error>> {
|
||||
Ok(self
|
||||
.client
|
||||
.patch(self.url(&format!("/repos/{}/releases/{release_id}", self.repository)))
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(payload)
|
||||
.send()?
|
||||
.error_for_status()?
|
||||
.json()?)
|
||||
}
|
||||
|
||||
fn delete_asset(&self, release_id: u64, asset_id: u64) -> Result<(), Box<dyn Error>> {
|
||||
self.client
|
||||
.delete(self.url(&format!(
|
||||
"/repos/{}/releases/{release_id}/assets/{asset_id}",
|
||||
self.repository
|
||||
)))
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload_asset(&self, release_id: u64, path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| format!("invalid release asset name: {}", path.display()))?;
|
||||
for attempt in 1..=3 {
|
||||
let file = File::open(path)?;
|
||||
let length = file.metadata()?.len();
|
||||
let body = Body::sized(file, length);
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url(&format!(
|
||||
"/repos/{}/releases/{release_id}/assets",
|
||||
self.repository
|
||||
)))
|
||||
.query(&[("name", name)])
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(body)
|
||||
.send();
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
println!("uploaded {}", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
Ok(response)
|
||||
if attempt < 3
|
||||
&& (response.status().is_server_error()
|
||||
|| response.status() == StatusCode::TOO_MANY_REQUESTS) => {}
|
||||
Ok(response) => {
|
||||
return Err(response.error_for_status().unwrap_err().into());
|
||||
}
|
||||
Err(error) if attempt < 3 => {
|
||||
eprintln!("asset upload attempt {attempt} failed: {error}");
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
thread::sleep(Duration::from_secs(attempt));
|
||||
}
|
||||
unreachable!("upload retry loop always returns")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare() -> Result<(), Box<dyn Error>> {
|
||||
let gitea = Gitea::from_environment()?;
|
||||
let tag = required_env("GITEA_REF_NAME")?;
|
||||
let sha = required_env("GITEA_SHA")?;
|
||||
let server = required_env("GITEA_SERVER_URL")?;
|
||||
let messages = commit_messages(&sha)?;
|
||||
let issues = issue_numbers(&messages)
|
||||
.into_iter()
|
||||
.map(|number| gitea.issue(number))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let closed = issues
|
||||
.iter()
|
||||
.filter(|issue| issue.state == "closed")
|
||||
.collect::<Vec<_>>();
|
||||
let payload = ReleasePayload {
|
||||
tag_name: &tag,
|
||||
target_commitish: &sha,
|
||||
name: &tag,
|
||||
body: release_body(closed.iter().copied(), &server, &gitea.repository, &sha),
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
};
|
||||
|
||||
let release = if let Some(existing) = gitea.release_for_tag(&tag)? {
|
||||
if !existing.draft {
|
||||
return Err(format!("release {tag} is already published").into());
|
||||
}
|
||||
for asset in existing.assets.unwrap_or_default() {
|
||||
gitea.delete_asset(existing.id, asset.id)?;
|
||||
}
|
||||
gitea.update_release(existing.id, &payload)?
|
||||
} else {
|
||||
gitea.create_release(&payload)?
|
||||
};
|
||||
|
||||
let output_path = required_env("GITHUB_OUTPUT")?;
|
||||
writeln!(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(output_path)?,
|
||||
"release_id={}",
|
||||
release.id
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn upload<'a>(paths: impl Iterator<Item = &'a Path>) -> Result<(), Box<dyn Error>> {
|
||||
let gitea = Gitea::from_environment()?;
|
||||
let release_id = required_env("RELEASE_ID")?.parse()?;
|
||||
for path in paths {
|
||||
gitea.upload_asset(release_id, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn publish() -> Result<(), Box<dyn Error>> {
|
||||
let gitea = Gitea::from_environment()?;
|
||||
let release_id = required_env("RELEASE_ID")?.parse()?;
|
||||
gitea.update_release(release_id, &serde_json::json!({ "draft": false }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_env(name: &str) -> Result<String, Box<dyn Error>> {
|
||||
env::var(name).map_err(|_| format!("required environment variable {name} is missing").into())
|
||||
}
|
||||
|
||||
fn commit_messages(sha: &str) -> Result<String, Box<dyn Error>> {
|
||||
let previous = Command::new("git")
|
||||
.args(["describe", "--tags", "--abbrev=0", &format!("{sha}^")])
|
||||
.output()?;
|
||||
let previous = if previous.status.success() {
|
||||
String::from_utf8(previous.stdout)?.trim().to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let revision = if previous.is_empty() {
|
||||
sha.to_owned()
|
||||
} else {
|
||||
format!("{previous}..{sha}")
|
||||
};
|
||||
let output = Command::new("git")
|
||||
.args(["log", "--format=%s%n%b", &revision])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("git log failed for {revision}").into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
||||
fn issue_numbers(message: &str) -> Vec<u64> {
|
||||
let bytes = message.as_bytes();
|
||||
let mut numbers = BTreeSet::new();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] != b'#' {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index + 1;
|
||||
let mut end = start;
|
||||
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
||||
end += 1;
|
||||
}
|
||||
if end > start
|
||||
&& let Ok(number) = message[start..end].parse()
|
||||
{
|
||||
numbers.insert(number);
|
||||
}
|
||||
index = end.max(index + 1);
|
||||
}
|
||||
numbers.into_iter().collect()
|
||||
}
|
||||
|
||||
fn release_body<'a>(
|
||||
issues: impl Iterator<Item = &'a Issue>,
|
||||
server: &str,
|
||||
repository: &str,
|
||||
sha: &str,
|
||||
) -> String {
|
||||
let mut fixes = Vec::new();
|
||||
let mut improvements = Vec::new();
|
||||
let mut other = Vec::new();
|
||||
for issue in issues {
|
||||
let title = issue.title.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let entry = format!(
|
||||
"- [#{} {}]({})",
|
||||
issue.number,
|
||||
markdown(&title),
|
||||
issue.html_url
|
||||
);
|
||||
let labels = issue
|
||||
.labels
|
||||
.iter()
|
||||
.map(|label| label.name.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if labels.contains("bug") {
|
||||
fixes.push(entry);
|
||||
} else if labels.contains("enhancement") {
|
||||
improvements.push(entry);
|
||||
} else {
|
||||
other.push(entry);
|
||||
}
|
||||
}
|
||||
let mut sections = Vec::new();
|
||||
for (title, entries) in [
|
||||
("Fixes", fixes),
|
||||
("Improvements", improvements),
|
||||
("Other changes", other),
|
||||
] {
|
||||
if !entries.is_empty() {
|
||||
sections.push(format!("## {title}\n{}", entries.join("\n")));
|
||||
}
|
||||
}
|
||||
if sections.is_empty() {
|
||||
sections.push("No closed issues were linked from commits in this release.".to_owned());
|
||||
}
|
||||
let short_sha = &sha[..sha.len().min(12)];
|
||||
sections.push(format!(
|
||||
"Built from [{short_sha}]({}/{repository}/commit/{sha}).",
|
||||
server.trim_end_matches('/')
|
||||
));
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn markdown(text: &str) -> String {
|
||||
text.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push_str(&format!("{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn issue_references_are_unique_and_sorted() {
|
||||
assert_eq!(
|
||||
issue_numbers("Fix #12, refs #3 and #12; ignore #x"),
|
||||
[3, 12]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_notes_are_grouped_linked_and_escaped() {
|
||||
let issues = [
|
||||
Issue {
|
||||
number: 12,
|
||||
title: "Fix [unlock]".to_owned(),
|
||||
html_url: "https://example.test/issues/12".to_owned(),
|
||||
state: "closed".to_owned(),
|
||||
labels: vec![Label {
|
||||
name: "bug".to_owned(),
|
||||
}],
|
||||
},
|
||||
Issue {
|
||||
number: 3,
|
||||
title: "Add search".to_owned(),
|
||||
html_url: "https://example.test/issues/3".to_owned(),
|
||||
state: "closed".to_owned(),
|
||||
labels: vec![Label {
|
||||
name: "enhancement".to_owned(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
let body = release_body(
|
||||
issues.iter(),
|
||||
"https://example.test",
|
||||
"owner/repo",
|
||||
"1234567890abcdef",
|
||||
);
|
||||
assert!(body.contains("## Fixes\n- [#12 Fix \\[unlock\\]]"));
|
||||
assert!(body.contains("## Improvements\n- [#3 Add search]"));
|
||||
assert!(
|
||||
body.contains(
|
||||
"[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_tag_is_encoded_as_one_api_path_segment() {
|
||||
assert_eq!(percent_encode("release/1+beta"), "release%2F1%2Bbeta");
|
||||
}
|
||||
}
|
||||
43
crates/bds-release/src/main.rs
Normal file
43
crates/bds-release/src/main.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
mod gitea;
|
||||
mod packaging;
|
||||
|
||||
use std::{env, error::Error, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||
match args.as_slice() {
|
||||
[command] if command == "prepare" => gitea::prepare(),
|
||||
[command] if command == "publish" => gitea::publish(),
|
||||
[command, paths @ ..] if command == "upload" && !paths.is_empty() => {
|
||||
gitea::upload(paths.iter().map(Path::new))
|
||||
}
|
||||
[command, tag, release_dir, rust_lib_dir, platform, dist_dir]
|
||||
if command == "package" =>
|
||||
{
|
||||
packaging::package(
|
||||
tag,
|
||||
Path::new(release_dir),
|
||||
Path::new(rust_lib_dir),
|
||||
platform,
|
||||
Path::new(dist_dir),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
[command, tag, release_dir, rust_lib_dir, platform, dist_dir, upload]
|
||||
if command == "package" && upload == "--upload" =>
|
||||
{
|
||||
let assets = packaging::package(
|
||||
tag,
|
||||
Path::new(release_dir),
|
||||
Path::new(rust_lib_dir),
|
||||
platform,
|
||||
Path::new(dist_dir),
|
||||
)?;
|
||||
gitea::upload(assets.iter().map(|path| path.as_path()))
|
||||
}
|
||||
_ => Err(
|
||||
"usage: bds-release prepare|publish|upload <asset>...|package <tag> <release-dir> <rust-lib-dir> <platform> <dist-dir> [--upload]"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
561
crates/bds-release/src/packaging.rs
Normal file
561
crates/bds-release/src/packaging.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user