feat(xtask): cross-platform release build via cargo-zigbuild
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled

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:
Eric Ratliff
2026-03-16 19:54:33 -05:00
parent 13ab202880
commit e7995de547
8 changed files with 768 additions and 209 deletions

19
xtask/Cargo.toml Normal file
View 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
View 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);
}
}