build: add cargo xtask pack for distributable archives

`cargo xtask pack [--target T] [--format zip|tar.gz] [--out-dir DIR] [--debug]`
builds the client and dedicated server and packs them with assets,
README and changelog into dist/station-<version>-<target>.(zip|tar.gz).
Windows targets default to zip, others to tar.gz.

Packaging uses a new `dist` profile (release + strip), which shrinks the
binaries considerably without changing regular release builds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-18 20:08:45 +02:00
parent a3c55bc8b0
commit 0b258a9361
9 changed files with 463 additions and 5 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target /target
/saves /saves
__pycache__/ __pycache__/
/.NOCOMMIT /.NOCOMMIT
dist

View File

@ -47,6 +47,8 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`). - `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`. - `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art. - `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
- `xtask/`: project automation (`cargo xtask pack` builds and archives the
client and server into `dist/`).
- `tools/gen_placeholder_assets.py`: regenerates placeholder art and the - `tools/gen_placeholder_assets.py`: regenerates placeholder art and the
example map (generated output is committed). example map (generated output is committed).
- `.github/workflows/`, `.gitea/workflows/`: CI for GitHub and Gitea. - `.github/workflows/`, `.gitea/workflows/`: CI for GitHub and Gitea.
@ -89,9 +91,10 @@ cargo nextest run --workspace
``` ```
Run binaries with `cargo run` (client; `--host`, `--connect`) and Run binaries with `cargo run` (client; `--host`, `--connect`) and
`cargo run -p game_server --bin game-server`. The Windows client can be `cargo run -p game_server --bin game-server`. Build a distributable archive
cross-built with `cargo build --release -p game_client --target with `cargo xtask pack` (add `--target x86_64-pc-windows-gnu` to cross-build
x86_64-pc-windows-gnu` (needs MinGW). Running the client requires a Vulkan or for Windows; needs MinGW). Files shipped next to the binaries are listed in
`EXTRA_FILES` in `xtask/src/main.rs`. Running the client requires a Vulkan or
GL capable GPU or a software driver such as lavapipe. GL capable GPU or a software driver such as lavapipe.
## Conventions ## Conventions

View File

@ -18,3 +18,5 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Crafting, construction/deconstruction, skills, hunger, health and respawning. - Crafting, construction/deconstruction, skills, hunger, health and respawning.
- Windows client builds for GitHub and Gitea CI; release builds run without a - Windows client builds for GitHub and Gitea CI; release builds run without a
console window. console window.
- `cargo xtask pack` builds the client and server and packs them with their
assets into a distributable archive; CI publishes these archives.

50
Cargo.lock generated
View File

@ -2638,6 +2638,16 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@ -5428,6 +5438,16 @@ dependencies = [
"slotmap", "slotmap",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.4.1" version = "1.4.1"
@ -5686,6 +5706,12 @@ version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -6659,6 +6685,16 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
dependencies = [
"flate2",
"serde_json",
"tar",
"zip",
]
[[package]] [[package]]
name = "xxhash-rust" name = "xxhash-rust"
version = "0.8.19" version = "0.8.19"
@ -6782,6 +6818,20 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "zip"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"

View File

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/game_core", "crates/game_server", "crates/game_client"] members = ["crates/game_core", "crates/game_server", "crates/game_client", "xtask"]
default-members = ["crates/game_client"] default-members = ["crates/game_client"]
[workspace.package] [workspace.package]
@ -25,6 +25,11 @@ opt-level = 1
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3
# Used by `cargo xtask pack` for distributable builds.
[profile.dist]
inherits = "release"
strip = true
[workspace.lints.clippy] [workspace.lints.clippy]
# Bevy system queries are naturally verbose. # Bevy system queries are naturally verbose.
type_complexity = "allow" type_complexity = "allow"

View File

@ -185,3 +185,17 @@ cargo clippy --workspace --all-targets
Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`); Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`);
its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a
JUnit report to `target/nextest/ci/junit.xml`. JUnit report to `target/nextest/ci/junit.xml`.
### Packaging
```sh
cargo xtask pack # for the host platform
cargo xtask pack --target x86_64-pc-windows-gnu # cross-compile for Windows (needs MinGW)
```
`pack` builds the client (`game`) and the dedicated server (`game-server`) with the
stripped `dist` profile and writes `dist/station-<version>-<target>.zip` (Windows) or
`.tar.gz` (other platforms). The archive contains both binaries, `assets/`, the README
and the changelog. Unpack it and run the binaries from that directory. The unpacked
staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
`--out-dir DIR`, `--debug`.

14
xtask/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
flate2 = "1"
serde_json = "1"
tar = { version = "0.4", default-features = false }
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
[lints]
workspace = true

367
xtask/src/main.rs Normal file
View File

@ -0,0 +1,367 @@
//! Project automation, run with `cargo xtask <command>`.
//!
//! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
//! ```
use std::{
env,
error::Error,
fs::{self, File},
io::{self, BufRead, BufReader, BufWriter, Write},
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
};
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const PACKAGE_NAME: &str = "station";
/// `(package, binary)` pairs shipped in the archive.
const BINARIES: [(&str, &str); 2] = [("game_client", "game"), ("game_server", "game-server")];
/// Files and directories (relative to the workspace root) shipped next to the binaries.
const EXTRA_FILES: [&str; 3] = ["assets", "README.md", "CHANGELOG.md"];
fn main() -> ExitCode {
let mut args = env::args().skip(1);
let result = match args.next().as_deref() {
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)),
Some("-h" | "--help") | None => {
print_help();
return ExitCode::SUCCESS;
}
Some(other) => Err(format!("unknown command `{other}`, see `cargo xtask --help`").into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}
fn print_help() {
println!(
"usage: cargo xtask <command>\n\n\
commands:\n \
pack build the client and server and pack them with their assets\n\n\
pack options:\n \
--target TRIPLE cross-compile for TRIPLE (default: host)\n \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
--out-dir DIR output directory (default: dist)\n \
--debug pack a debug build instead of the stripped `dist` profile"
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Format {
Zip,
TarGz,
}
impl Format {
fn parse(s: &str) -> Result<Self> {
match s {
"zip" => Ok(Self::Zip),
"tar.gz" | "tgz" => Ok(Self::TarGz),
_ => Err(format!("unknown archive format `{s}` (expected zip or tar.gz)").into()),
}
}
fn default_for(target: &str) -> Self {
if target.contains("windows") {
Self::Zip
} else {
Self::TarGz
}
}
fn extension(self) -> &'static str {
match self {
Self::Zip => "zip",
Self::TarGz => "tar.gz",
}
}
}
#[derive(Debug, Default)]
struct PackOptions {
target: Option<String>,
format: Option<Format>,
out_dir: Option<PathBuf>,
debug: bool,
}
impl PackOptions {
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
let mut opts = Self::default();
while let Some(arg) = args.next() {
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
match arg.as_str() {
"--target" => opts.target = Some(value("--target")?),
"--format" => opts.format = Some(Format::parse(&value("--format")?)?),
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
"--debug" => opts.debug = true,
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
other => return Err(format!("unknown pack option `{other}`").into()),
}
}
Ok(opts)
}
}
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask lives inside the workspace")
.to_path_buf()
}
fn cargo() -> Command {
Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
}
fn host_target() -> Result<String> {
let output = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
.arg("-vV")
.output()?;
String::from_utf8(output.stdout)?
.lines()
.find_map(|line| line.strip_prefix("host: "))
.map(str::to_owned)
.ok_or_else(|| "could not determine the host target from `rustc -vV`".into())
}
fn pack(opts: &PackOptions) -> Result<()> {
let root = workspace_root();
let target = match &opts.target {
Some(target) => target.clone(),
None => host_target()?,
};
let format = opts.format.unwrap_or_else(|| Format::default_for(&target));
let out_dir = root.join(opts.out_dir.as_deref().unwrap_or(Path::new("dist")));
let name = format!("{PACKAGE_NAME}-{}-{target}", env!("CARGO_PKG_VERSION"));
let executables = build(&root, opts.target.as_deref(), opts.debug)?;
// The staging directory is kept so CI can upload it without double-zipping.
let stage = out_dir.join(&name);
if stage.exists() {
fs::remove_dir_all(&stage)?;
}
fs::create_dir_all(&stage)?;
for exe in &executables {
fs::copy(
exe,
stage.join(exe.file_name().expect("executable has a file name")),
)?;
}
for extra in EXTRA_FILES {
copy_recursive(&root.join(extra), &stage.join(extra))?;
}
let archive = out_dir.join(format!("{name}.{}", format.extension()));
write_archive(&stage, &name, &archive, format)?;
println!("packed {}", archive.display());
Ok(())
}
/// Builds the shipped binaries and returns their paths, as reported by cargo.
fn build(root: &Path, target: Option<&str>, debug: bool) -> Result<Vec<PathBuf>> {
let mut cmd = cargo();
cmd.current_dir(root).args([
"build",
"--locked",
"--message-format=json-render-diagnostics",
]);
cmd.args(["--profile", if debug { "dev" } else { "dist" }]);
if let Some(target) = target {
cmd.args(["--target", target]);
}
for (package, bin) in BINARIES {
cmd.args(["-p", package, "--bin", bin]);
}
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
let mut executables = Vec::new();
for line in BufReader::new(child.stdout.take().expect("stdout is piped")).lines() {
let msg: serde_json::Value = serde_json::from_str(&line?)?;
if msg["reason"] == "compiler-artifact"
&& let Some(exe) = msg["executable"].as_str()
&& BINARIES
.iter()
.any(|(_, bin)| msg["target"]["name"] == *bin)
{
executables.push(PathBuf::from(exe));
}
}
if !child.wait()?.success() {
return Err("cargo build failed".into());
}
if executables.len() != BINARIES.len() {
return Err(format!(
"expected {} executables, cargo reported {executables:?}",
BINARIES.len()
)
.into());
}
Ok(executables)
}
fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
if from.is_dir() {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
copy_recursive(&entry.path(), &to.join(entry.file_name()))?;
}
Ok(())
} else {
fs::copy(from, to).map(|_| ())
}
}
/// Files below `dir` as `(path, archive path with '/' separators)`, sorted for reproducible archives.
fn collect_files(dir: &Path, prefix: &str) -> io::Result<Vec<(PathBuf, String)>> {
let mut files = Vec::new();
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<io::Result<_>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
if entry.file_type()?.is_dir() {
files.extend(collect_files(&entry.path(), &name)?);
} else {
files.push((entry.path(), name));
}
}
Ok(files)
}
fn is_executable(archive_path: &str) -> bool {
let file = archive_path.rsplit('/').next().unwrap_or(archive_path);
BINARIES
.iter()
.any(|(_, bin)| file == *bin || file == format!("{bin}.exe"))
}
/// Packs the contents of `dir` into `archive`, below a top-level directory `prefix`.
fn write_archive(dir: &Path, prefix: &str, archive: &Path, format: Format) -> Result<()> {
let files = collect_files(dir, prefix)?;
let out = BufWriter::new(File::create(archive)?);
match format {
Format::Zip => {
let mut zip = zip::ZipWriter::new(out);
for (path, name) in files {
let mode = if is_executable(&name) { 0o755 } else { 0o644 };
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(mode);
zip.start_file(name, options)?;
io::copy(&mut File::open(path)?, &mut zip)?;
}
zip.finish()?.flush()?;
}
Format::TarGz => {
let gz = flate2::write::GzEncoder::new(out, flate2::Compression::default());
let mut tar = tar::Builder::new(gz);
for (path, name) in files {
let mut header = tar::Header::new_gnu();
header.set_size(fs::metadata(&path)?.len());
header.set_mode(if is_executable(&name) { 0o755 } else { 0o644 });
header.set_mtime(0);
header.set_cksum();
tar.append_data(&mut header, &name, File::open(&path)?)?;
}
tar.into_inner()?.finish()?.flush()?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("xtask-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("assets/data")).unwrap();
fs::write(dir.join("game"), b"binary").unwrap();
fs::write(dir.join("assets/data/items.rsn"), b"items: []").unwrap();
dir
}
#[test]
fn default_format_follows_target() {
assert_eq!(Format::default_for("x86_64-pc-windows-msvc"), Format::Zip);
assert_eq!(Format::default_for("x86_64-pc-windows-gnu"), Format::Zip);
assert_eq!(
Format::default_for("x86_64-unknown-linux-gnu"),
Format::TarGz
);
}
#[test]
fn parses_pack_options() {
let args = ["--target", "x", "--format", "zip", "--debug"].map(String::from);
let opts = PackOptions::parse(args.into_iter()).unwrap();
assert_eq!(opts.target.as_deref(), Some("x"));
assert_eq!(opts.format, Some(Format::Zip));
assert!(opts.debug);
assert!(PackOptions::parse(["--target".to_owned()].into_iter()).is_err());
assert!(PackOptions::parse(["--bogus".to_owned()].into_iter()).is_err());
}
#[test]
fn zip_contains_prefixed_files_with_permissions() {
let dir = temp_dir("zip");
let archive = dir.with_extension("zip");
write_archive(&dir, "pkg", &archive, Format::Zip).unwrap();
let mut zip = zip::ZipArchive::new(File::open(&archive).unwrap()).unwrap();
let mut names: Vec<_> = zip.file_names().map(|n| n.unwrap().into_owned()).collect();
names.sort();
assert_eq!(names, ["pkg/assets/data/items.rsn", "pkg/game"]);
assert_eq!(
zip.by_name("pkg/game").unwrap().unix_mode().unwrap() & 0o777,
0o755
);
fs::remove_dir_all(&dir).unwrap();
fs::remove_file(&archive).unwrap();
}
#[test]
fn tar_gz_contains_prefixed_files_with_permissions() {
let dir = temp_dir("tar");
let archive = dir.with_extension("tar.gz");
write_archive(&dir, "pkg", &archive, Format::TarGz).unwrap();
let gz = flate2::read::GzDecoder::new(File::open(&archive).unwrap());
let mut tar = tar::Archive::new(gz);
let entries: Vec<_> = tar
.entries()
.unwrap()
.map(|e| {
let e = e.unwrap();
(
e.path().unwrap().to_string_lossy().into_owned(),
e.header().mode().unwrap(),
)
})
.collect();
assert_eq!(
entries,
[
("pkg/assets/data/items.rsn".to_owned(), 0o644),
("pkg/game".to_owned(), 0o755)
]
);
fs::remove_dir_all(&dir).unwrap();
fs::remove_file(&archive).unwrap();
}
}