feat(xtask): cross-platform release build via cargo-zigbuild
Replace build-all.sh shell script with a Rust xtask workspace member. Uses cargo-zigbuild + zig as a universal cross-linker -- no VMs, no containers, no root required. Produces all three release binaries from a single FreeBSD machine: anvil-X.Y.Z-freebsd-x86_64.tar.gz (native cargo build) anvil-X.Y.Z-linux-x86_64.tar.gz (cargo zigbuild) anvil-X.Y.Z-windows-x86_64.zip (cargo zigbuild) Commands: cargo xtask --fix install zig, zip, cargo-zigbuild, rustup targets cargo xtask --check verify all dependencies cargo xtask build all three binaries + SHA256SUMS cargo xtask --clean remove cross-compile artifacts cargo xtask --suffix rc1 build with version suffix Also converts Cargo.toml to a workspace (members: anvil, xtask). build-all.sh retained as a thin wrapper around cargo xtask.
This commit is contained in:
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# This alias makes `cargo xtask` work -- it runs the xtask workspace member.
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -1072,6 +1072,16 @@ version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"colored",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[workspace]
|
||||
members = [".", "xtask"]
|
||||
default-members = ["."]
|
||||
|
||||
[package]
|
||||
name = "anvil"
|
||||
version = "1.0.0"
|
||||
|
||||
112
README.md
112
README.md
@@ -377,65 +377,18 @@ Anvil is written in Rust and compiles to a single static binary. You need:
|
||||
|
||||
- **Rust toolchain** (stable, 2021 edition or later)
|
||||
- **A C linker** (`gcc` or equivalent -- Rust uses it under the hood)
|
||||
- **MinGW** (only if cross-compiling a Windows binary from Linux)
|
||||
- **zip** (only for packaging release artifacts)
|
||||
|
||||
### Linux / WSL from scratch
|
||||
|
||||
A fresh Ubuntu or WSL instance needs three commands:
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# 1. System packages (C linker + cross-compile + packaging tools)
|
||||
sudo apt update && sudo apt install build-essential mingw-w64 zip
|
||||
|
||||
# 2. Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
|
||||
# 3. Build
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The release binary lands at `target/release/anvil`. Copy it somewhere in
|
||||
your PATH.
|
||||
|
||||
### Windows (native)
|
||||
|
||||
Install Rust from [rustup.rs](https://rustup.rs), which includes the MSVC
|
||||
toolchain. Then:
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The binary lands at `target\release\anvil.exe`.
|
||||
|
||||
### Release builds (Linux + Windows from one machine)
|
||||
|
||||
The `build-release.sh` script at the repo root builds optimized, stripped
|
||||
binaries for both platforms and packages them into tarballs and zips. It
|
||||
reads the version from `Cargo.toml` (the single source of truth) and
|
||||
accepts an optional suffix for pre-release builds:
|
||||
|
||||
```bash
|
||||
./build-release.sh # uses version from Cargo.toml (e.g. 1.0.0)
|
||||
./build-release.sh beta1 # appends suffix (e.g. 1.0.0-beta1)
|
||||
./build-release.sh rc1 # appends suffix (e.g. 1.0.0-rc1)
|
||||
```
|
||||
|
||||
This produces a `release-artifacts/` directory with:
|
||||
|
||||
```
|
||||
anvil-1.0.0-linux-x86_64.tar.gz
|
||||
anvil-1.0.0-linux-x86_64.zip
|
||||
anvil-1.0.0-windows-x86_64.zip
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
Upload these to a Gitea release. The script requires `build-essential`,
|
||||
`mingw-w64`, and `zip` as described above.
|
||||
The release binary lands at `target/release/anvil` (or `target\release\anvil.exe`
|
||||
on Windows). Copy it somewhere in your PATH.
|
||||
|
||||
### Running the test suite
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
@@ -449,6 +402,7 @@ skip gracefully and everything else still passes.
|
||||
#### Full test suite on Linux / WSL
|
||||
|
||||
The e2e tests need `cmake`, `g++`, and `arduino-cli` with the AVR core:
|
||||
|
||||
```bash
|
||||
sudo apt install cmake g++
|
||||
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
|
||||
@@ -460,6 +414,7 @@ arduino-cli core install arduino:avr
|
||||
|
||||
Install [arduino-cli](https://arduino.github.io/arduino-cli/installation/)
|
||||
and add it to your PATH, then install the AVR core:
|
||||
|
||||
```
|
||||
arduino-cli core install arduino:avr
|
||||
```
|
||||
@@ -470,6 +425,61 @@ a Visual Studio Developer Command Prompt (which provides `cl.exe`).
|
||||
|
||||
---
|
||||
|
||||
## Building Release Binaries
|
||||
|
||||
Anvil uses a Rust `xtask` workspace member to build release binaries for all
|
||||
platforms from a single machine. No VMs, no containers, no root required.
|
||||
|
||||
### How it works
|
||||
|
||||
The xtask build system uses [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild)
|
||||
with the [Zig](https://ziglang.org) compiler as a universal cross-linker.
|
||||
From a single FreeBSD machine you get all three platform binaries:
|
||||
|
||||
```
|
||||
release-artifacts/
|
||||
anvil-X.Y.Z-freebsd-x86_64.tar.gz native FreeBSD build
|
||||
anvil-X.Y.Z-linux-x86_64.tar.gz cross-compiled via zig
|
||||
anvil-X.Y.Z-windows-x86_64.zip cross-compiled via zig
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
cargo xtask --fix
|
||||
```
|
||||
|
||||
This installs zig (via pkg), zip, cargo-zigbuild, and the required rustup
|
||||
cross-compile targets. Only the zig and zip installation steps require sudo.
|
||||
|
||||
### Building a release
|
||||
|
||||
```bash
|
||||
cargo xtask # build all platforms, version from Cargo.toml
|
||||
cargo xtask --suffix rc1 # pre-release (e.g. 1.0.0-rc1)
|
||||
```
|
||||
|
||||
### Other commands
|
||||
|
||||
```bash
|
||||
cargo xtask --check # verify all dependencies are installed
|
||||
cargo xtask --clean # remove cross-compile artifacts
|
||||
```
|
||||
|
||||
### Platform support
|
||||
|
||||
| Host | FreeBSD binary | Linux binary | Windows binary |
|
||||
|---|---|---|---|
|
||||
| FreeBSD | native | cargo-zigbuild | cargo-zigbuild |
|
||||
| Linux | not possible | native | cargo-zigbuild |
|
||||
| Windows | not possible | not tested | native |
|
||||
|
||||
FreeBSD is the recommended build host -- it produces all three binaries in
|
||||
a single run.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT -- see [LICENSE](LICENSE).
|
||||
158
build-release.sh
158
build-release.sh
@@ -1,158 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Build release binaries for distribution.
|
||||
# Builds a native binary for the current OS, plus Windows (cross-compile).
|
||||
#
|
||||
# Usage:
|
||||
# ./build-release.sh # version from Cargo.toml (e.g. 1.0.0)
|
||||
# ./build-release.sh beta1 # appends suffix (e.g. 1.0.0-beta1)
|
||||
# ./build-release.sh rc1 # appends suffix (e.g. 1.0.0-rc1)
|
||||
#
|
||||
# Supported host platforms:
|
||||
# Linux -- builds linux-x86_64 + windows-x86_64 (via MinGW)
|
||||
# FreeBSD -- builds freebsd-x86_64 + windows-x86_64 (via MinGW)
|
||||
# Windows -- use build-release.ps1 instead
|
||||
#
|
||||
# To produce all three native binaries, run this script on both
|
||||
# Linux and FreeBSD, then collect artifacts from both runs.
|
||||
#
|
||||
# Cross-compile Linux<->FreeBSD is not supported (requires a full sysroot).
|
||||
set -e
|
||||
|
||||
# Detect host OS
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Linux) HOST="linux" ;;
|
||||
FreeBSD) HOST="freebsd" ;;
|
||||
Darwin) HOST="macos" ;;
|
||||
*)
|
||||
echo "Unsupported host OS: $OS"
|
||||
echo "Use build-release.ps1 on Windows."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Read version from the single source of truth: Cargo.toml
|
||||
BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' | tr -d '\r')
|
||||
if [ -z "$BASE_VERSION" ]; then
|
||||
echo "Error: Could not read version from Cargo.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Optional pre-release suffix (beta1, rc1, etc.)
|
||||
# Strip a leading 'v' if passed by accident (e.g. v1.0.0 -> ignored, 1.0.0 used from Cargo.toml)
|
||||
SUFFIX="${1#v}"
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${BASE_VERSION}-${SUFFIX}"
|
||||
else
|
||||
VERSION="$BASE_VERSION"
|
||||
fi
|
||||
|
||||
RELEASE_DIR="release-artifacts"
|
||||
|
||||
echo "Building Anvil v${VERSION} release binaries..."
|
||||
echo " Host platform: ${HOST}"
|
||||
echo " Version base: ${BASE_VERSION} (from Cargo.toml)"
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
echo " Suffix: ${SUFFIX}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean previous artifacts
|
||||
rm -rf "$RELEASE_DIR"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
# -- Native binary -----------------------------------------------------------
|
||||
echo "Building ${HOST} x86_64 binary..."
|
||||
cargo build --release
|
||||
strip target/release/anvil 2>/dev/null || true
|
||||
|
||||
echo "Packaging ${HOST} binary..."
|
||||
cd target/release
|
||||
tar -czf "../../$RELEASE_DIR/anvil-${VERSION}-${HOST}-x86_64.tar.gz" anvil
|
||||
cd ../..
|
||||
|
||||
# -- Windows binary (cross-compile via MinGW) --------------------------------
|
||||
echo ""
|
||||
echo "Building Windows x86_64 binary (cross-compile)..."
|
||||
|
||||
# rustup is required to add cross-compile targets.
|
||||
# On FreeBSD, Rust is typically installed via pkg and rustup is not available.
|
||||
if ! command -v rustup > /dev/null 2>&1; then
|
||||
echo " rustup not found -- Windows cross-compile requires rustup."
|
||||
case "$HOST" in
|
||||
freebsd) echo " On FreeBSD, install rustup via: curl https://sh.rustup.rs -sSf | sh" ;;
|
||||
*) echo " Install rustup from: https://rustup.rs" ;;
|
||||
esac
|
||||
echo "Skipping Windows build."
|
||||
elif ! command -v x86_64-w64-mingw32-gcc > /dev/null 2>&1; then
|
||||
case "$HOST" in
|
||||
linux)
|
||||
echo " MinGW not found. Install with: sudo apt install mingw-w64"
|
||||
;;
|
||||
freebsd)
|
||||
echo " MinGW not available in FreeBSD pkg repos."
|
||||
echo " Windows binary must be built on Linux (boot into Linux or use a bhyve VM)."
|
||||
;;
|
||||
esac
|
||||
echo "Skipping Windows build."
|
||||
else
|
||||
# Ensure Windows target is installed
|
||||
if ! rustup target list | grep -q "x86_64-pc-windows-gnu (installed)"; then
|
||||
echo "Installing Windows cross-compile target..."
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
x86_64-w64-mingw32-strip target/x86_64-pc-windows-gnu/release/anvil.exe 2>/dev/null || true
|
||||
|
||||
echo "Packaging Windows binary..."
|
||||
cd target/x86_64-pc-windows-gnu/release
|
||||
zip -q "../../../$RELEASE_DIR/anvil-${VERSION}-windows-x86_64.zip" anvil.exe
|
||||
cd ../../..
|
||||
fi
|
||||
|
||||
# -- Checksums ---------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Generating checksums..."
|
||||
cd "$RELEASE_DIR"
|
||||
|
||||
# sha256sum on Linux, sha256 on FreeBSD/macOS
|
||||
if command -v sha256sum > /dev/null 2>&1; then
|
||||
sha256sum * > SHA256SUMS
|
||||
else
|
||||
shasum -a 256 * > SHA256SUMS
|
||||
fi
|
||||
cd ..
|
||||
|
||||
# -- Summary -----------------------------------------------------------------
|
||||
echo ""
|
||||
echo "==========================================================="
|
||||
echo " Anvil v${VERSION} release artifacts built successfully!"
|
||||
echo "==========================================================="
|
||||
echo ""
|
||||
echo "Artifacts in $RELEASE_DIR/:"
|
||||
ls -lh "$RELEASE_DIR"
|
||||
echo ""
|
||||
echo "Checksums:"
|
||||
cat "$RELEASE_DIR/SHA256SUMS"
|
||||
echo ""
|
||||
case "$HOST" in
|
||||
freebsd)
|
||||
echo "FreeBSD binary built. To also ship a Linux binary:"
|
||||
echo " Option 1 (easiest): boot into Linux and run ./build-release.sh"
|
||||
echo " Option 2: run this script inside a Linux bhyve VM"
|
||||
echo " Option 3: set up a Linux CI runner (e.g. Gitea Actions)"
|
||||
echo " Then collect all artifacts before uploading to Gitea."
|
||||
;;
|
||||
linux)
|
||||
echo "Linux binary built. To also ship a FreeBSD binary:"
|
||||
echo " Boot into FreeBSD and run ./build-release.sh"
|
||||
echo " Then collect all artifacts before uploading to Gitea."
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
echo "Upload to Gitea:"
|
||||
echo " 1. Create release: Releases -> New Release -> Tag: v${VERSION}"
|
||||
echo " 2. Drag and drop files from $RELEASE_DIR/"
|
||||
echo " 3. Save"
|
||||
echo ""
|
||||
@@ -1019,6 +1019,7 @@ fn is_freebsd() -> bool {
|
||||
cfg!(target_os = "freebsd")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn serial_group_name() -> &'static str {
|
||||
if is_freebsd() {
|
||||
"dialer"
|
||||
@@ -1027,6 +1028,7 @@ fn serial_group_name() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn serial_group_fix_command() -> String {
|
||||
if is_freebsd() {
|
||||
"sudo pw groupmod dialer -m $USER".to_string()
|
||||
@@ -1071,6 +1073,8 @@ fn check_operator_group() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(dead_code)]
|
||||
fn whoami() -> String {
|
||||
std::process::Command::new("whoami")
|
||||
.output()
|
||||
|
||||
19
xtask/Cargo.toml
Normal file
19
xtask/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI argument parsing
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
# Colors
|
||||
colored = "2.1"
|
||||
# Command existence check
|
||||
which = "5.0"
|
||||
667
xtask/src/main.rs
Normal file
667
xtask/src/main.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
//! xtask -- build automation for Anvil.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo xtask Build all platform binaries
|
||||
//! cargo xtask --check Check dependencies
|
||||
//! cargo xtask --fix Install missing dependencies
|
||||
//! cargo xtask --clean Remove zig/cargo cross-compile cache
|
||||
//! cargo xtask --suffix rc1 Build with version suffix (e.g. 1.0.0-rc1)
|
||||
//!
|
||||
//! Produces:
|
||||
//! release-artifacts/anvil-X.Y.Z-freebsd-x86_64.tar.gz (native)
|
||||
//! release-artifacts/anvil-X.Y.Z-linux-x86_64.tar.gz (via cargo-zigbuild)
|
||||
//! release-artifacts/anvil-X.Y.Z-windows-x86_64.zip (via cargo-zigbuild)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "xtask",
|
||||
about = "Build Anvil release binaries for all platforms",
|
||||
long_about = "Builds FreeBSD (native), Linux, and Windows binaries using cargo-zigbuild.\n\
|
||||
No VMs, no root, no containers required.\n\n\
|
||||
First time: cargo xtask --fix\n\
|
||||
Every release: cargo xtask"
|
||||
)]
|
||||
struct Args {
|
||||
/// Check dependencies and report what is missing
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
|
||||
/// Install missing dependencies (zig, cargo-zigbuild, rustup targets)
|
||||
#[arg(long)]
|
||||
fix: bool,
|
||||
|
||||
/// Remove cargo target directories for cross-compile targets
|
||||
#[arg(long)]
|
||||
clean: bool,
|
||||
|
||||
/// Version suffix (e.g. rc1 -> 1.0.0-rc1)
|
||||
#[arg(long, value_name = "SUFFIX")]
|
||||
suffix: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct Target {
|
||||
name: &'static str,
|
||||
triple: &'static str,
|
||||
binary_name: &'static str,
|
||||
archive_ext: &'static str,
|
||||
}
|
||||
|
||||
const TARGETS: &[Target] = &[
|
||||
Target {
|
||||
name: "Linux x86_64",
|
||||
triple: "x86_64-unknown-linux-gnu",
|
||||
binary_name: "anvil",
|
||||
archive_ext: "tar.gz",
|
||||
},
|
||||
Target {
|
||||
name: "Windows x86_64",
|
||||
triple: "x86_64-pc-windows-gnu",
|
||||
binary_name: "anvil.exe",
|
||||
archive_ext: "zip",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ok(msg: &str) {
|
||||
println!("{} {}", "ok".green(), msg);
|
||||
}
|
||||
|
||||
fn warn(msg: &str) {
|
||||
println!("{} {}", "warn".yellow(), msg);
|
||||
}
|
||||
|
||||
fn info(msg: &str) {
|
||||
println!("{} {}", "....".cyan(), msg);
|
||||
}
|
||||
|
||||
fn fail(msg: &str) {
|
||||
eprintln!("{} {}", "FAIL".red(), msg);
|
||||
}
|
||||
|
||||
fn header(msg: &str) {
|
||||
println!("\n{}\n", format!("=== {} ===", msg).bold());
|
||||
}
|
||||
|
||||
fn cmd_exists(cmd: &str) -> bool {
|
||||
which::which(cmd).is_ok()
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run: {} {}", program, args.join(" ")))?;
|
||||
if !status.success() {
|
||||
bail!("{} {} failed with exit code: {}", program, args.join(" "), status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_captured(program: &str, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run: {}", program))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Find the workspace root.
|
||||
/// When run via `cargo xtask`, cargo sets CARGO_MANIFEST_DIR to the xtask
|
||||
/// package directory. The workspace root is one level up from there.
|
||||
fn workspace_root() -> Result<PathBuf> {
|
||||
// CARGO_MANIFEST_DIR points to xtask/ -- parent is the workspace root
|
||||
if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
|
||||
let xtask_dir = PathBuf::from(manifest_dir);
|
||||
if let Some(parent) = xtask_dir.parent() {
|
||||
if parent.join("Cargo.toml").exists() {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: walk up from current dir looking for a workspace Cargo.toml
|
||||
let mut dir = env::current_dir()?;
|
||||
loop {
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
// Check if it's a workspace (contains [workspace])
|
||||
let contents = fs::read_to_string(&cargo_toml).unwrap_or_default();
|
||||
if contents.contains("[workspace]") {
|
||||
return Ok(dir);
|
||||
}
|
||||
}
|
||||
if !dir.pop() {
|
||||
bail!("Could not find workspace root (no Cargo.toml with [workspace] found)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read version from workspace Cargo.toml
|
||||
fn read_version(workspace: &Path) -> Result<String> {
|
||||
let cargo_toml = fs::read_to_string(workspace.join("Cargo.toml"))
|
||||
.context("Failed to read Cargo.toml")?;
|
||||
for line in cargo_toml.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("version") && line.contains('=') {
|
||||
if let Some(v) = line.split('=').nth(1) {
|
||||
let v = v.trim().trim_matches('"');
|
||||
return Ok(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("Could not read version from Cargo.toml")
|
||||
}
|
||||
|
||||
/// Detect host OS
|
||||
fn host_os() -> &'static str {
|
||||
if cfg!(target_os = "freebsd") {
|
||||
"freebsd"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive binary into release-artifacts/
|
||||
fn package_binary(
|
||||
workspace: &Path,
|
||||
triple: &str,
|
||||
binary_name: &str,
|
||||
archive_name: &str,
|
||||
archive_ext: &str,
|
||||
) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
let binary_path = workspace
|
||||
.join("target")
|
||||
.join(triple)
|
||||
.join("release")
|
||||
.join(binary_name);
|
||||
|
||||
if !binary_path.exists() {
|
||||
// Show what's actually in the target dir to help diagnose
|
||||
let target_dir = binary_path.parent().unwrap();
|
||||
let entries: Vec<String> = fs::read_dir(target_dir)
|
||||
.map(|rd| rd.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
bail!(
|
||||
"Binary not found: {}\n Contents of {}:\n {}",
|
||||
binary_path.display(),
|
||||
target_dir.display(),
|
||||
if entries.is_empty() { "(empty)".to_string() } else { entries.join(", ") }
|
||||
);
|
||||
}
|
||||
|
||||
match archive_ext {
|
||||
"tar.gz" => {
|
||||
let archive = artifacts.join(format!("{}.tar.gz", archive_name));
|
||||
run(
|
||||
"tar",
|
||||
&[
|
||||
"-czf",
|
||||
archive.to_str().unwrap(),
|
||||
"-C",
|
||||
binary_path.parent().unwrap().to_str().unwrap(),
|
||||
binary_name,
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
"zip" => {
|
||||
let archive = artifacts.join(format!("{}.zip", archive_name));
|
||||
// -j = junk paths (store just the filename, not the full path)
|
||||
run(
|
||||
"zip",
|
||||
&["-q", "-j", archive.to_str().unwrap(), binary_path.to_str().unwrap()],
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
_ => bail!("Unknown archive format: {}", archive_ext),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Package native FreeBSD binary from target/release/
|
||||
fn package_native(workspace: &Path, archive_name: &str) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
let binary_path = workspace.join("target").join("release").join("anvil");
|
||||
if !binary_path.exists() {
|
||||
bail!("Native binary not found: {}", binary_path.display());
|
||||
}
|
||||
|
||||
let archive = artifacts.join(format!("{}.tar.gz", archive_name));
|
||||
run(
|
||||
"tar",
|
||||
&[
|
||||
"-czf",
|
||||
archive.to_str().unwrap(),
|
||||
"-C",
|
||||
binary_path.parent().unwrap().to_str().unwrap(),
|
||||
"anvil",
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate SHA256SUMS in release-artifacts/
|
||||
fn generate_checksums(workspace: &Path) -> Result<()> {
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
let prev = env::current_dir()?;
|
||||
env::set_current_dir(&artifacts)?;
|
||||
|
||||
// Collect artifact files
|
||||
let mut files: Vec<String> = fs::read_dir(&artifacts)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name != "SHA256SUMS" {
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
if cmd_exists("sha256sum") {
|
||||
let mut args = vec!["--"];
|
||||
for f in &files {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
let output = Command::new("sha256sum")
|
||||
.args(&args)
|
||||
.output()?;
|
||||
fs::write("SHA256SUMS", &output.stdout)?;
|
||||
} else if cmd_exists("shasum") {
|
||||
let mut args = vec!["-a", "256", "--"];
|
||||
for f in &files {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
let output = Command::new("shasum")
|
||||
.args(&args)
|
||||
.output()?;
|
||||
fs::write("SHA256SUMS", &output.stdout)?;
|
||||
}
|
||||
|
||||
let checksums = fs::read_to_string("SHA256SUMS").unwrap_or_default();
|
||||
print!("{}", checksums);
|
||||
|
||||
env::set_current_dir(prev)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_check(_workspace: &Path) -> Result<bool> {
|
||||
header("Checking dependencies");
|
||||
let mut missing = 0;
|
||||
|
||||
// zig
|
||||
if cmd_exists("zig") {
|
||||
let ver = run_captured("zig", &["version"]).unwrap_or_default();
|
||||
ok(&format!("zig {}", ver));
|
||||
} else {
|
||||
warn("zig -- not found");
|
||||
println!(" {}", "sudo pkg install zig (FreeBSD) or apt install zig (Linux)");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// zip (for Windows archive)
|
||||
if cmd_exists("zip") {
|
||||
ok("zip");
|
||||
} else {
|
||||
warn("zip -- not found");
|
||||
println!(" {}", "sudo pkg install zip (FreeBSD) or apt install zip (Linux)");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// cargo-zigbuild
|
||||
if cmd_exists("cargo-zigbuild") {
|
||||
let ver = run_captured("cargo-zigbuild", &["--version"])
|
||||
.unwrap_or_default();
|
||||
ok(&format!("cargo-zigbuild {}", ver.replace("cargo-zigbuild ", "")));
|
||||
} else {
|
||||
warn("cargo-zigbuild -- not found");
|
||||
println!(" {}", "cargo install cargo-zigbuild");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// rustup targets
|
||||
if cmd_exists("rustup") {
|
||||
let targets = run_captured("rustup", &["target", "list", "--installed"])
|
||||
.unwrap_or_default();
|
||||
for t in TARGETS {
|
||||
if targets.contains(t.triple) {
|
||||
ok(&format!("rustup target: {}", t.triple));
|
||||
} else {
|
||||
warn(&format!("rustup target: {} -- not installed", t.triple));
|
||||
println!(" rustup target add {}", t.triple);
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn("rustup -- not found");
|
||||
println!(" {}", "curl https://sh.rustup.rs -sSf | sh");
|
||||
missing += 1;
|
||||
}
|
||||
|
||||
// Host platform note
|
||||
let host = host_os();
|
||||
if host == "linux" {
|
||||
println!();
|
||||
warn("Running on Linux -- FreeBSD binary cannot be produced.");
|
||||
println!(" Boot into FreeBSD and run 'cargo xtask' for the FreeBSD binary.");
|
||||
} else if host != "freebsd" {
|
||||
println!();
|
||||
warn(&format!("Unsupported host OS: {}", host));
|
||||
}
|
||||
|
||||
println!();
|
||||
if missing == 0 {
|
||||
ok("All dependencies satisfied -- ready to build!");
|
||||
Ok(true)
|
||||
} else {
|
||||
warn(&format!("{} dependency/dependencies missing. Run: cargo xtask --fix", missing));
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_fix() -> Result<()> {
|
||||
header("Installing dependencies");
|
||||
|
||||
let host = host_os();
|
||||
|
||||
// zig
|
||||
if cmd_exists("zig") {
|
||||
ok("zig already installed");
|
||||
} else {
|
||||
info("Installing zig...");
|
||||
match host {
|
||||
"freebsd" => run("sudo", &["pkg", "install", "-y", "zig"], None)?,
|
||||
"linux" => {
|
||||
// Try common package managers
|
||||
if cmd_exists("apt-get") {
|
||||
run("sudo", &["apt-get", "install", "-y", "zig"], None)?;
|
||||
} else if cmd_exists("dnf") {
|
||||
run("sudo", &["dnf", "install", "-y", "zig"], None)?;
|
||||
} else {
|
||||
bail!("Could not install zig automatically. Install manually: https://ziglang.org/download/");
|
||||
}
|
||||
}
|
||||
_ => bail!("Cannot auto-install zig on {}. Install manually: https://ziglang.org/download/", host),
|
||||
}
|
||||
ok("zig installed");
|
||||
}
|
||||
|
||||
// zip (for Windows archive packaging)
|
||||
if cmd_exists("zip") {
|
||||
ok("zip already installed");
|
||||
} else {
|
||||
info("Installing zip...");
|
||||
match host {
|
||||
"freebsd" => run("sudo", &["pkg", "install", "-y", "zip"], None)?,
|
||||
"linux" => {
|
||||
if cmd_exists("apt-get") {
|
||||
run("sudo", &["apt-get", "install", "-y", "zip"], None)?;
|
||||
} else if cmd_exists("dnf") {
|
||||
run("sudo", &["dnf", "install", "-y", "zip"], None)?;
|
||||
} else {
|
||||
bail!("Could not install zip automatically. Install it manually.");
|
||||
}
|
||||
}
|
||||
_ => bail!("Cannot auto-install zip on {}. Install it manually.", host),
|
||||
}
|
||||
ok("zip installed");
|
||||
}
|
||||
|
||||
// cargo-zigbuild
|
||||
if cmd_exists("cargo-zigbuild") {
|
||||
ok("cargo-zigbuild already installed");
|
||||
} else {
|
||||
info("Installing cargo-zigbuild...");
|
||||
run("cargo", &["install", "cargo-zigbuild"], None)?;
|
||||
ok("cargo-zigbuild installed");
|
||||
}
|
||||
|
||||
// rustup targets
|
||||
if cmd_exists("rustup") {
|
||||
let installed = run_captured("rustup", &["target", "list", "--installed"])
|
||||
.unwrap_or_default();
|
||||
for t in TARGETS {
|
||||
if installed.contains(t.triple) {
|
||||
ok(&format!("rustup target {} already installed", t.triple));
|
||||
} else {
|
||||
info(&format!("Adding rustup target {}...", t.triple));
|
||||
run("rustup", &["target", "add", t.triple], None)?;
|
||||
ok(&format!("rustup target {} added", t.triple));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bail!("rustup not found -- install it first: curl https://sh.rustup.rs -sSf | sh");
|
||||
}
|
||||
|
||||
println!();
|
||||
ok("All dependencies installed. Run: cargo xtask");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clean
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_clean(workspace: &Path) -> Result<()> {
|
||||
header("Cleaning cross-compile artifacts");
|
||||
|
||||
for t in TARGETS {
|
||||
let target_dir = workspace.join("target").join(t.triple);
|
||||
if target_dir.exists() {
|
||||
info(&format!("Removing target/{}/...", t.triple));
|
||||
fs::remove_dir_all(&target_dir)?;
|
||||
ok(&format!("target/{} removed", t.triple));
|
||||
} else {
|
||||
ok(&format!("target/{} already clean", t.triple));
|
||||
}
|
||||
}
|
||||
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
if artifacts.exists() {
|
||||
info("Removing release-artifacts/...");
|
||||
fs::remove_dir_all(&artifacts)?;
|
||||
ok("release-artifacts removed");
|
||||
}
|
||||
|
||||
println!();
|
||||
ok("Clean complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_build(workspace: &Path, version: &str) -> Result<()> {
|
||||
let host = host_os();
|
||||
|
||||
println!();
|
||||
println!("{}", format!("Building Anvil v{} -- all platforms", version).bold());
|
||||
println!(" Version: {} (from Cargo.toml)", version);
|
||||
println!(" Host: {}", host);
|
||||
|
||||
// Clean previous artifacts
|
||||
let artifacts = workspace.join("release-artifacts");
|
||||
if artifacts.exists() {
|
||||
fs::remove_dir_all(&artifacts)?;
|
||||
}
|
||||
fs::create_dir_all(&artifacts)?;
|
||||
|
||||
// -- FreeBSD native ----------------------------------------------------
|
||||
if host == "freebsd" {
|
||||
header("FreeBSD x86_64");
|
||||
info("Building...");
|
||||
run("cargo", &["build", "--release"], Some(workspace))?;
|
||||
let archive_name = format!("anvil-{}-freebsd-x86_64", version);
|
||||
package_native(workspace, &archive_name)?;
|
||||
ok(&format!("{}.tar.gz", archive_name));
|
||||
} else if host == "linux" {
|
||||
println!();
|
||||
warn("Running on Linux -- FreeBSD binary cannot be produced.");
|
||||
println!(" Boot into FreeBSD and run 'cargo xtask' for the FreeBSD binary.");
|
||||
println!();
|
||||
}
|
||||
|
||||
// -- Linux x86_64 via cargo-zigbuild -----------------------------------
|
||||
header("Linux x86_64 (via cargo-zigbuild)");
|
||||
info("Building...");
|
||||
run(
|
||||
"cargo",
|
||||
&["zigbuild", "--release", "--target", "x86_64-unknown-linux-gnu"],
|
||||
Some(workspace),
|
||||
)?;
|
||||
let archive_name = format!("anvil-{}-linux-x86_64", version);
|
||||
package_binary(
|
||||
workspace,
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"anvil",
|
||||
&archive_name,
|
||||
"tar.gz",
|
||||
)?;
|
||||
ok(&format!("{}.tar.gz", archive_name));
|
||||
|
||||
// -- Windows x86_64 via cargo-zigbuild ---------------------------------
|
||||
header("Windows x86_64 (via cargo-zigbuild)");
|
||||
info("Building...");
|
||||
run(
|
||||
"cargo",
|
||||
&["zigbuild", "--release", "--target", "x86_64-pc-windows-gnu"],
|
||||
Some(workspace),
|
||||
)?;
|
||||
let archive_name = format!("anvil-{}-windows-x86_64", version);
|
||||
package_binary(
|
||||
workspace,
|
||||
"x86_64-pc-windows-gnu",
|
||||
"anvil.exe",
|
||||
&archive_name,
|
||||
"zip",
|
||||
)?;
|
||||
ok(&format!("{}.zip", archive_name));
|
||||
|
||||
// -- Checksums ---------------------------------------------------------
|
||||
header("Checksums");
|
||||
generate_checksums(workspace)?;
|
||||
|
||||
// -- Summary -----------------------------------------------------------
|
||||
println!();
|
||||
println!("{}", format!("=== Anvil v{} ready ===", version).bold().green());
|
||||
println!();
|
||||
for entry in fs::read_dir(&artifacts)?.flatten() {
|
||||
let meta = entry.metadata()?;
|
||||
println!(
|
||||
" {} ({:.1} MB)",
|
||||
entry.file_name().to_string_lossy(),
|
||||
meta.len() as f64 / 1_048_576.0
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!("Upload to Gitea:");
|
||||
println!(" 1. Releases -> New Release -> Tag: v{}", version);
|
||||
println!(" 2. Drag and drop everything from release-artifacts/");
|
||||
println!(" 3. Save");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let workspace = workspace_root().expect("Could not find workspace root");
|
||||
|
||||
let result = if args.clean {
|
||||
run_clean(&workspace)
|
||||
} else if args.check {
|
||||
match run_check(&workspace) {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => std::process::exit(1),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else if args.fix {
|
||||
run_fix()
|
||||
} else {
|
||||
// Build -- check deps first
|
||||
match run_check(&workspace) {
|
||||
Ok(false) => {
|
||||
println!();
|
||||
fail("Dependencies missing.");
|
||||
fail("Run: cargo xtask --fix");
|
||||
println!();
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&format!("Dependency check failed: {}", e));
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let base_version =
|
||||
read_version(&workspace).expect("Could not read version from Cargo.toml");
|
||||
let version = match &args.suffix {
|
||||
Some(s) => format!("{}-{}", base_version, s.trim_start_matches('v')),
|
||||
None => base_version,
|
||||
};
|
||||
|
||||
run_build(&workspace, &version)
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
fail(&format!("{:#}", e));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user