From e7995de54702a2c8189e65741f06805128ce08e7 Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Mon, 16 Mar 2026 19:54:33 -0500 Subject: [PATCH] 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. --- .cargo/config.toml | 3 + Cargo.lock | 10 + Cargo.toml | 4 + README.md | 112 +++---- build-release.sh | 158 ---------- src/commands/doctor.rs | 4 + xtask/Cargo.toml | 19 ++ xtask/src/main.rs | 667 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 768 insertions(+), 209 deletions(-) create mode 100644 .cargo/config.toml delete mode 100755 build-release.sh create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..434b656 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +# This alias makes `cargo xtask` work -- it runs the xtask workspace member. +[alias] +xtask = "run --package xtask --" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bc7ce36..3050673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c75a906..9f79ea4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "xtask"] +default-members = ["."] + [package] name = "anvil" version = "1.0.0" diff --git a/README.md b/README.md index 03b6c95..149ddf9 100644 --- a/README.md +++ b/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). \ No newline at end of file diff --git a/build-release.sh b/build-release.sh deleted file mode 100755 index d8383cb..0000000 --- a/build-release.sh +++ /dev/null @@ -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 "" \ No newline at end of file diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 028e9dd..1836371 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -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() diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..c93d82b --- /dev/null +++ b/xtask/Cargo.toml @@ -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" \ No newline at end of file diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..1ef9a88 --- /dev/null +++ b/xtask/src/main.rs @@ -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, +} + +// --------------------------------------------------------------------------- +// 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 { + 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 { + // 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 { + 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 = 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 = 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 { + 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); + } +} \ No newline at end of file