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:
68
.gitea/scripts/install-linux-build-dependencies.sh
Normal file
68
.gitea/scripts/install-linux-build-dependencies.sh
Normal 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
|
||||||
219
.gitea/workflows/release.yml
Normal file
219
.gitea/workflows/release.yml
Normal 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
|
||||||
8
BUILD.md
8
BUILD.md
@@ -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.
|
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 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)
|
- 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)
|
## Linux system requirements (optional, for CI or cross-platform development)
|
||||||
|
|||||||
1804
Cargo.lock
generated
1804
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -8,11 +8,12 @@ members = [
|
|||||||
"crates/bds-mcp",
|
"crates/bds-mcp",
|
||||||
"crates/bds-server",
|
"crates/bds-server",
|
||||||
"crates/bds-package-runtime",
|
"crates/bds-package-runtime",
|
||||||
|
"crates/bds-release",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
version = "0.1.0"
|
version = "0.9.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Georg Bauer <gb@rfc1437.de>"]
|
authors = ["Georg Bauer <gb@rfc1437.de>"]
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ regex = "1"
|
|||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
pagefind = "1.5.2"
|
pagefind = "1.5.2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
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 = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
libsqlite3-sys = { version = "0.37.0", features = ["bundled"] }
|
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-mcp = { path = "crates/bds-mcp" }
|
||||||
bds-server = { path = "crates/bds-server" }
|
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]
|
[profile.release]
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|||||||
@@ -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.
|
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
|
## Repository Map
|
||||||
|
|
||||||
- `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua
|
- `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-cli` — headless automation CLI over the shared engines
|
||||||
- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine
|
- `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-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
|
- `specs` — authoritative Allium behavior specifications
|
||||||
- `fixtures` — compatibility projects and generated-site fixtures
|
- `fixtures` — compatibility projects and generated-site fixtures
|
||||||
- `locales` — UI and native-menu translations
|
- `locales` — UI and native-menu translations
|
||||||
|
|||||||
@@ -822,7 +822,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
state.is_focused = false;
|
state.is_focused = false;
|
||||||
}
|
}
|
||||||
@@ -871,7 +870,6 @@ where
|
|||||||
buf.set_cursor(clamped_line, clamped_col);
|
buf.set_cursor(clamped_line, clamped_col);
|
||||||
}
|
}
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
||||||
@@ -885,7 +883,6 @@ where
|
|||||||
let max_scroll = total.saturating_sub(vis);
|
let max_scroll = total.saturating_sub(vis);
|
||||||
buf.scroll_by_clamped(lines, max_scroll);
|
buf.scroll_by_clamped(lines, max_scroll);
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key,
|
key,
|
||||||
@@ -1099,7 +1096,6 @@ where
|
|||||||
_ => return,
|
_ => return,
|
||||||
}
|
}
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ objc2-foundation = { version = "0.3", features = ["objc2-core-services"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fluent-syntax = { workspace = true }
|
fluent-syntax = { workspace = true }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
syn = { version = "3", features = ["full", "visit"] }
|
syn = { version = "3", features = ["full", "visit"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
|||||||
@@ -3872,7 +3872,7 @@ impl BdsApp {
|
|||||||
menu::action_enabled(
|
menu::action_enabled(
|
||||||
action,
|
action,
|
||||||
self.active_project.is_some(),
|
self.active_project.is_some(),
|
||||||
self.active_tab_type(),
|
self.active_tab_type().as_ref(),
|
||||||
self.offline_mode,
|
self.offline_mode,
|
||||||
!self.search_index_rebuild_running,
|
!self.search_index_rebuild_running,
|
||||||
) && (action != MenuAction::DisconnectServer || self.remote_client.is_some())
|
) && (action != MenuAction::DisconnectServer || self.remote_client.is_some())
|
||||||
|
|||||||
@@ -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("Georg Bauer <gb@rfc1437.de>"));
|
||||||
assert!(workspace_manifest.contains("strip = \"symbols\""));
|
assert!(workspace_manifest.contains("strip = \"symbols\""));
|
||||||
assert!(workspace_manifest.contains("ort-download-binaries-rustls-tls"));
|
assert!(workspace_manifest.contains("ort-download-binaries-rustls-tls"));
|
||||||
|
assert!(workspace_manifest.contains("\"sync-secret-service\", \"vendored\""));
|
||||||
|
|
||||||
let core_manifest =
|
let core_manifest =
|
||||||
fs::read_to_string(Path::new(CRATE_DIR).join("../bds-core/Cargo.toml")).unwrap();
|
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("CARGO_PACKAGER_FORMAT"));
|
||||||
assert!(!packager.contains("--options"));
|
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"));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user