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,68 @@
#!/bin/sh
set -eu
target=${1:?usage: install-linux-build-dependencies.sh arm64|x64}
install_packages() {
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
}
case "$target" in
arm64)
sudo apt-get update
install_packages \
build-essential \
cmake \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
pkg-config
;;
x64)
if [ "$(dpkg --print-architecture)" != "arm64" ]; then
echo "the x64 release build expects an arm64 Ubuntu runner" >&2
exit 1
fi
if [ ! -r /etc/os-release ]; then
echo "cannot determine the Ubuntu release" >&2
exit 1
fi
. /etc/os-release
if [ "${ID:-}" != "ubuntu" ] || [ -z "${VERSION_CODENAME:-}" ]; then
echo "the x64 release build requires an Ubuntu runner" >&2
exit 1
fi
sudo dpkg --add-architecture amd64
{
printf '%s\n' \
'Types: deb' \
'URIs: http://archive.ubuntu.com/ubuntu' \
"Suites: $VERSION_CODENAME ${VERSION_CODENAME}-updates" \
'Components: main universe restricted multiverse' \
'Architectures: amd64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
'' \
'Types: deb' \
'URIs: http://security.ubuntu.com/ubuntu' \
"Suites: ${VERSION_CODENAME}-security" \
'Components: main universe restricted multiverse' \
'Architectures: amd64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg'
} | sudo tee /etc/apt/sources.list.d/ruds-amd64.sources >/dev/null
sudo apt-get update
install_packages \
build-essential \
cmake \
gcc-x86-64-linux-gnu \
g++-x86-64-linux-gnu \
libgtk-3-dev:amd64 \
libwebkit2gtk-4.1-dev:amd64 \
libxdo-dev:amd64 \
pkg-config
;;
*)
echo "unsupported Linux build target: $target" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,219 @@
name: Tagged release
on:
push:
tags:
- "*"
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.97.1
jobs:
prepare-release:
runs-on: linux-arm64
outputs:
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Check out the tag
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the release Rust toolchain
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal
- name: Test release tooling
run: cargo +${RUST_TOOLCHAIN} test --locked --package bds-release
- name: Create draft release
id: release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- prepare
build-macos:
needs: prepare-release
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
env:
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS: -C link-arg=-mmacosx-version-min=26.0 -C link-arg=-Wl,-rpath,@executable_path/../Resources -C link-arg=-Wl,-headerpad,0x1000
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: -C link-arg=-mmacosx-version-min=26.0 -C link-arg=-Wl,-rpath,@executable_path/../Resources -C link-arg=-Wl,-headerpad,0x1000
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Install release Rust targets
run: >-
rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal
--target aarch64-apple-darwin
--target x86_64-apple-darwin
- name: Build, package, and upload macOS releases
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
for build in \
'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64' \
'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64'
do
target=${build%%|*}
rest=${build#*|}
build_target=${rest%%|*}
platform=${rest#*|}
cargo +${RUST_TOOLCHAIN} zigbuild --release --locked --target "$build_target" \
--package bds-ui --package bds-cli --package bds-mcp
rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir)
cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" "$platform" dist --upload
done
build-linux-arm64:
needs: prepare-release
runs-on: linux-arm64
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-Wl,-rpath,$ORIGIN
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install native Linux desktop dependencies
run: sh .gitea/scripts/install-linux-build-dependencies.sh arm64
- name: Install the release Rust target
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --target aarch64-unknown-linux-gnu
- name: Build, package, and upload Linux arm64 release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=aarch64-unknown-linux-gnu
cargo +${RUST_TOOLCHAIN} 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)
cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" linux-arm64 dist --upload
build-linux-x64:
needs: prepare-release
runs-on: linux-arm64
env:
AR_x86_64_unknown_linux_gnu: x86_64-linux-gnu-ar
CC_x86_64_unknown_linux_gnu: x86_64-linux-gnu-gcc
CXX_x86_64_unknown_linux_gnu: x86_64-linux-gnu-g++
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-Wl,-rpath,$ORIGIN -L native=/usr/lib/x86_64-linux-gnu
PKG_CONFIG_ALLOW_CROSS: "1"
PKG_CONFIG_LIBDIR: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install x64 Linux desktop dependencies and cross-linker
run: sh .gitea/scripts/install-linux-build-dependencies.sh x64
- name: Install the release Rust target
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --target x86_64-unknown-linux-gnu
- name: Build, package, and upload Linux x64 release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=x86_64-unknown-linux-gnu
cargo +${RUST_TOOLCHAIN} 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)
cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" linux-x64 dist --upload
build-windows:
needs: prepare-release
runs-on: linux-arm64
container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
env:
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS: -C target-feature=+crt-static
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Install the release Rust target
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --target x86_64-pc-windows-msvc
- name: Build, package, and upload Windows release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=x86_64-pc-windows-msvc
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)
cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" windows-x64 dist --upload
publish-release:
needs:
- prepare-release
- build-macos
- build-linux-arm64
- build-linux-x64
- build-windows
runs-on: linux-arm64
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install the release Rust toolchain
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal
- name: Publish complete release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: cargo +${RUST_TOOLCHAIN} run --release --locked --package bds-release -- publish

View File

@@ -20,9 +20,15 @@ Packages are written below `target/release`. The macOS bundle uses the ICNS icon
Windows packaging requires the MSVC Rust toolchain, Visual Studio Build Tools with C++ support, and the Windows SDK. Build each package on its target operating system; these commands do not cross-package installers.
## Automated tagged releases
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.
## macOS system requirements
- macOS 13 (Ventura) or later
- macOS 26 or later
- Apple Silicon or Intel Mac with Metal or Vulkan support (required by Iced's wgpu backend)
## Linux system requirements (optional, for CI or cross-platform development)

1804
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,12 @@ members = [
"crates/bds-mcp",
"crates/bds-server",
"crates/bds-package-runtime",
"crates/bds-release",
]
[workspace.package]
edition = "2024"
version = "0.1.0"
version = "0.9.0"
license = "MIT"
authors = ["Georg Bauer <gb@rfc1437.de>"]
@@ -52,7 +53,7 @@ regex = "1"
rayon = "1.10"
pagefind = "1.5.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "vendored"] }
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = "2.3.2"
libsqlite3-sys = { version = "0.37.0", features = ["bundled"] }
@@ -84,5 +85,16 @@ bds-editor = { path = "crates/bds-editor" }
bds-mcp = { path = "crates/bds-mcp" }
bds-server = { path = "crates/bds-server" }
# Portable release packaging
apple-bundles = "0.21"
apple-codesign = { version = "0.29", default-features = false }
apple-dmg = "0.5"
flate2 = "1"
icns = "0.4"
plist = "1"
simple-file-manifest = "0.11"
tar = "0.4"
zip = { version = "8.6", default-features = false, features = ["deflate"] }
[profile.release]
strip = "symbols"

View File

@@ -39,6 +39,8 @@ Packaged executables share native `bds-core` and `bds-server` dynamic libraries.
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.
Pushing a tag creates a draft Gitea release, builds native artifacts on the Linux arm64 runner, and publishes only after every target succeeds. Releases contain CLI/MCP and desktop downloads for Linux arm64/x64, macOS arm64/x64, and Windows x64. Linux desktop binaries are dynamically linked to the GTK 3, WebKitGTK 4.1, and libxdo packages supplied by the target Ubuntu architecture. macOS desktop downloads are ad-hoc-signed DMGs; Unix archives use `.tar.gz` and Windows uses `.zip`. All release packaging and Gitea API work is performed by the workspace's Rust release utility.
## Repository Map
- `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua
@@ -47,6 +49,7 @@ Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID
- `crates/bds-cli` — headless automation CLI over the shared engines
- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine
- `crates/bds-server` — reusable headless host, SSH transport, remote protocol, and desktop client library
- `crates/bds-release` — portable tagged-release packaging and Gitea publishing utility
- `specs` — authoritative Allium behavior specifications
- `fixtures` — compatibility projects and generated-site fixtures
- `locales` — UI and native-menu translations

View File

@@ -822,7 +822,6 @@ where
}
}
shell.capture_event();
return;
} else {
state.is_focused = false;
}
@@ -871,7 +870,6 @@ where
buf.set_cursor(clamped_line, clamped_col);
}
shell.capture_event();
return;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
@@ -885,7 +883,6 @@ where
let max_scroll = total.saturating_sub(vis);
buf.scroll_by_clamped(lines, max_scroll);
shell.capture_event();
return;
}
Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -1099,7 +1096,6 @@ where
_ => return,
}
shell.capture_event();
return;
}
_ => {}
}

View 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"

View 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");
}
}

View 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(),
),
}
}

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");
}
}

View File

@@ -29,6 +29,7 @@ objc2-foundation = { version = "0.3", features = ["objc2-core-services"] }
[dev-dependencies]
fluent-syntax = { workspace = true }
serde_yaml = { workspace = true }
syn = { version = "3", features = ["full", "visit"] }
tempfile = "3"

View File

@@ -3872,7 +3872,7 @@ impl BdsApp {
menu::action_enabled(
action,
self.active_project.is_some(),
self.active_tab_type(),
self.active_tab_type().as_ref(),
self.offline_mode,
!self.search_index_rebuild_running,
) && (action != MenuAction::DisconnectServer || self.remote_client.is_some())

View File

@@ -60,6 +60,7 @@ fn desktop_packages_have_native_icons_and_cargo_commands() {
assert!(workspace_manifest.contains("Georg Bauer <gb@rfc1437.de>"));
assert!(workspace_manifest.contains("strip = \"symbols\""));
assert!(workspace_manifest.contains("ort-download-binaries-rustls-tls"));
assert!(workspace_manifest.contains("\"sync-secret-service\", \"vendored\""));
let core_manifest =
fs::read_to_string(Path::new(CRATE_DIR).join("../bds-core/Cargo.toml")).unwrap();
@@ -82,3 +83,86 @@ fn desktop_packages_have_native_icons_and_cargo_commands() {
assert!(packager.contains("CARGO_PACKAGER_FORMAT"));
assert!(!packager.contains("--options"));
}
#[test]
fn tagged_releases_are_built_and_packaged_with_rust_tools() {
let workspace = Path::new(CRATE_DIR).join("../..");
let manifest = fs::read_to_string(workspace.join("Cargo.toml")).unwrap();
assert!(manifest.contains("\"crates/bds-release\""));
for dependency in [
"apple-bundles = \"0.21\"",
"apple-codesign = { version = \"0.29\", default-features = false }",
"apple-dmg = \"0.5\"",
] {
assert!(manifest.contains(dependency), "missing {dependency}");
}
let release_manifest =
fs::read_to_string(workspace.join("crates/bds-release/Cargo.toml")).unwrap();
for dependency in [
"apple-bundles.workspace = true",
"apple-codesign.workspace = true",
"apple-dmg.workspace = true",
"flate2",
"tar",
"zip",
] {
assert!(
release_manifest.contains(dependency),
"release packager is missing {dependency}"
);
}
let workflow = fs::read_to_string(workspace.join(".gitea/workflows/release.yml")).unwrap();
serde_yaml::from_str::<serde_yaml::Value>(&workflow).expect("valid release workflow YAML");
for required in [
"tags:",
"runs-on: linux-arm64",
"cargo-zigbuild",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"cargo-xwin",
"x86_64-pc-windows-msvc",
"bds-release",
"prepare",
"package",
"publish",
"PKG_CONFIG_ALLOW_CROSS",
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER",
] {
assert!(
workflow.contains(required),
"release workflow is missing {required}"
);
}
for forbidden in ["python", "curl ", "hdiutil", "makensis", "tar -"] {
assert!(
!workflow.contains(forbidden),
"release workflow must not use host tool {forbidden}"
);
}
let linux_dependencies =
fs::read_to_string(workspace.join(".gitea/scripts/install-linux-build-dependencies.sh"))
.unwrap();
for required in [
"libwebkit2gtk-4.1-dev",
"libgtk-3-dev",
"libxdo-dev",
"libwebkit2gtk-4.1-dev:amd64",
"gcc-x86-64-linux-gnu",
] {
assert!(
linux_dependencies.contains(required),
"Linux dependency setup is missing {required}"
);
}
let release_source =
fs::read_to_string(workspace.join("crates/bds-release/src/packaging.rs")).unwrap();
assert!(release_source.contains("LC_ID_DYLIB"));
assert!(release_source.contains("rewrite_macho_rpaths"));
assert!(!release_source.contains("Linux releases contain the portable CLI tools"));
}