chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
readme.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
cargo_metadata = "0.23.1"
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
flate2 = "1.1.5"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tar = "0.4.44"
|
||||
tempfile = "3.23.0"
|
||||
time = { version = "0.3.47", features = ["formatting"] }
|
||||
walkdir = "2.5.0"
|
||||
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,237 @@
|
||||
#![expect(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "the cli module stays private while sibling xtask modules need crate-visible CLI types"
|
||||
)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{ArgAction, Args, Parser, Subcommand};
|
||||
|
||||
/// Top-level xtask CLI entrypoint.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author,
|
||||
version,
|
||||
about = "Cargo-native workflow entrypoints for aria2-rust-pro"
|
||||
)]
|
||||
pub(crate) struct Cli {
|
||||
/// Selected top-level subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: TopLevelCommand,
|
||||
}
|
||||
|
||||
/// Supported top-level xtask command groups.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum TopLevelCommand {
|
||||
/// Compatibility and golden-output workflows.
|
||||
Compat(CompatCommand),
|
||||
/// Docker packaging and smoke-test workflows.
|
||||
Docker(DockerCommand),
|
||||
/// Performance collection workflows.
|
||||
Perf(PerfCommand),
|
||||
/// Release packaging and validation workflows.
|
||||
Release(ReleaseCommand),
|
||||
/// Aggregated testing and lint-style sweeps.
|
||||
Testing(TestingCommand),
|
||||
}
|
||||
|
||||
/// Compatibility command group.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct CompatCommand {
|
||||
/// Selected compatibility subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: CompatSubcommand,
|
||||
}
|
||||
|
||||
/// Compatibility-focused subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum CompatSubcommand {
|
||||
/// Capture CLI output goldens from upstream and Rust binaries.
|
||||
CaptureCliGoldens(CaptureCliGoldensArgs),
|
||||
}
|
||||
|
||||
/// Arguments for capturing CLI golden outputs.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct CaptureCliGoldensArgs {
|
||||
/// Optional path to the upstream reference binary.
|
||||
#[arg(long)]
|
||||
pub(crate) upstream_binary: Option<PathBuf>,
|
||||
/// Optional path to the Rust implementation binary.
|
||||
#[arg(long)]
|
||||
pub(crate) rust_binary: Option<PathBuf>,
|
||||
/// Optional root directory where captured goldens are written.
|
||||
#[arg(long)]
|
||||
pub(crate) output_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Release command group.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct ReleaseCommand {
|
||||
/// Selected release subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: ReleaseSubcommand,
|
||||
}
|
||||
|
||||
/// Release-oriented subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ReleaseSubcommand {
|
||||
/// Validate the release binary reports the expected version string.
|
||||
SmokeVersion(ReleaseSmokeVersionArgs),
|
||||
/// Package a local release artifact layout.
|
||||
PackageLocal(PackageLocalArgs),
|
||||
}
|
||||
|
||||
/// Arguments for the release version smoke test.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct ReleaseSmokeVersionArgs {
|
||||
/// Build the binary before running the smoke test.
|
||||
#[arg(long)]
|
||||
pub(crate) build: bool,
|
||||
/// Optional target triple used when locating or building the binary.
|
||||
#[arg(long)]
|
||||
pub(crate) target_triple: Option<String>,
|
||||
/// Optional explicit path to the binary under test.
|
||||
#[arg(long)]
|
||||
pub(crate) binary_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Arguments for packaging a local release bundle.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct PackageLocalArgs {
|
||||
/// Build the binary before packaging it.
|
||||
#[arg(long)]
|
||||
pub(crate) build: bool,
|
||||
/// Optional target triple used when locating or building the binary.
|
||||
#[arg(long)]
|
||||
pub(crate) target_triple: Option<String>,
|
||||
/// Optional explicit path to the binary that should be packaged.
|
||||
#[arg(long)]
|
||||
pub(crate) source_binary: Option<PathBuf>,
|
||||
/// Optional root directory where packaged artifacts are written.
|
||||
#[arg(long)]
|
||||
pub(crate) output_root: Option<PathBuf>,
|
||||
/// Skip the version smoke test that normally runs before packaging.
|
||||
#[arg(long)]
|
||||
pub(crate) skip_version_smoke: bool,
|
||||
}
|
||||
|
||||
/// Testing command group.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct TestingCommand {
|
||||
/// Selected testing subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: TestingSubcommand,
|
||||
}
|
||||
|
||||
/// Testing-oriented subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum TestingSubcommand {
|
||||
/// Run the strict sweep across the xtask quality gates.
|
||||
StrictSweep(StrictSweepArgs),
|
||||
}
|
||||
|
||||
/// Arguments for the strict sweep workflow.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct StrictSweepArgs {
|
||||
/// Include release smoke checks as part of the sweep.
|
||||
#[arg(long)]
|
||||
pub(crate) include_release_smoke: bool,
|
||||
}
|
||||
|
||||
/// Docker command group.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct DockerCommand {
|
||||
/// Selected Docker subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: DockerSubcommand,
|
||||
}
|
||||
|
||||
/// Docker-oriented subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum DockerSubcommand {
|
||||
/// Export a locally built Docker image as a tarball.
|
||||
ExportLocal(ExportLocalArgs),
|
||||
/// Run the Docker smoke test against a tagged image.
|
||||
Smoke(DockerSmokeArgs),
|
||||
/// Run the Docker smoke test using the local defaults.
|
||||
SmokeLocal(DockerSmokeLocalArgs),
|
||||
}
|
||||
|
||||
/// Arguments for exporting a local Docker image.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct ExportLocalArgs {
|
||||
/// Docker image tag to build or export.
|
||||
#[arg(long, default_value = "aria2-rust-pro:local")]
|
||||
pub(crate) tag: String,
|
||||
/// Optional root directory where exported artifacts are written.
|
||||
#[arg(long)]
|
||||
pub(crate) output_root: Option<PathBuf>,
|
||||
/// Build the Docker image before exporting it.
|
||||
#[arg(long)]
|
||||
pub(crate) build: bool,
|
||||
}
|
||||
|
||||
/// Arguments for running the Docker smoke test.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct DockerSmokeArgs {
|
||||
/// Docker image tag to build or run during the smoke test.
|
||||
#[arg(long, default_value = "aria2-rust-pro:smoke")]
|
||||
pub(crate) tag: String,
|
||||
/// Build the Docker image before running the smoke test.
|
||||
#[arg(long, default_value_t = true, action = ArgAction::Set)]
|
||||
pub(crate) build: bool,
|
||||
/// Host RPC port mapped into the smoke-test container.
|
||||
#[arg(long, default_value_t = 26_800)]
|
||||
pub(crate) host_rpc_port: u16,
|
||||
/// RPC secret used by the smoke-test container.
|
||||
#[arg(long, default_value = "smoke-secret")]
|
||||
pub(crate) rpc_secret: String,
|
||||
/// Special mode passed into the smoke-test scenario.
|
||||
#[arg(long, default_value = "move")]
|
||||
pub(crate) special_mode: String,
|
||||
}
|
||||
|
||||
/// Arguments for the local Docker smoke shortcut.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct DockerSmokeLocalArgs {}
|
||||
|
||||
/// Performance command group.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct PerfCommand {
|
||||
/// Selected performance subcommand.
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: PerfSubcommand,
|
||||
}
|
||||
|
||||
/// Performance-oriented subcommands.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum PerfSubcommand {
|
||||
/// Collect local upstream-vs-Rust comparison data.
|
||||
CollectLocalComparison(CollectLocalComparisonArgs),
|
||||
}
|
||||
|
||||
/// Arguments for local comparison data collection.
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct CollectLocalComparisonArgs {
|
||||
/// Optional output file for the comparison report.
|
||||
#[arg(long)]
|
||||
pub(crate) output_path: Option<PathBuf>,
|
||||
/// Number of samples collected per benchmark case.
|
||||
#[arg(long, default_value_t = 10.0)]
|
||||
pub(crate) sample_size: f64,
|
||||
/// Measurement duration, in seconds, for each sample.
|
||||
#[arg(long, default_value_t = 0.05)]
|
||||
pub(crate) measurement_seconds: f64,
|
||||
/// Warmup duration, in seconds, before measurements begin.
|
||||
#[arg(long, default_value_t = 0.05)]
|
||||
pub(crate) warmup_seconds: f64,
|
||||
/// Transfer size, in bytes, used by the benchmark.
|
||||
#[arg(long, default_value_t = 8 * 1024 * 1024)]
|
||||
pub(crate) transfer_bytes: usize,
|
||||
/// Timed same-host transfer samples collected per binary and scenario after warmup.
|
||||
#[arg(long, default_value_t = 5)]
|
||||
pub(crate) same_host_runs: usize,
|
||||
/// Skip bench execution and only reuse existing output data.
|
||||
#[arg(long)]
|
||||
pub(crate) skip_bench_execution: bool,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
cli::CaptureCliGoldensArgs,
|
||||
workspace::{
|
||||
capture_stdout_lines, default_binary_path, load_workspace_metadata, utc_now_rfc3339,
|
||||
write_utf8_lines, write_utf8_text,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// JSON manifest written beside captured CLI compatibility goldens.
|
||||
struct CompatManifest {
|
||||
/// UTC generation timestamp for the manifest.
|
||||
generated_at_utc: String,
|
||||
/// Relative paths captured by the workflow.
|
||||
captured: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Human-readable JSON summary printed after capture.
|
||||
struct CompatSummary {
|
||||
#[serde(rename = "Captured")]
|
||||
/// Relative paths captured by the workflow.
|
||||
captured: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Captures upstream and Rust CLI help/version output as compatibility goldens.
|
||||
pub fn run_capture_cli_goldens(args: &CaptureCliGoldensArgs) -> Result<()> {
|
||||
let metadata = load_workspace_metadata()?;
|
||||
let host_triple = crate::workspace::host_triple()?;
|
||||
let upstream_binary = args
|
||||
.upstream_binary
|
||||
.clone()
|
||||
.unwrap_or_else(default_upstream_binary);
|
||||
let rust_binary = args
|
||||
.rust_binary
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_binary_path(&metadata, &host_triple, &host_triple));
|
||||
let output_root = args.output_root.clone().unwrap_or_else(|| {
|
||||
metadata
|
||||
.workspace_root
|
||||
.join("docs/compatibility/goldens/cli")
|
||||
});
|
||||
|
||||
ensure_path_exists(&upstream_binary, "upstream binary")?;
|
||||
ensure_path_exists(&rust_binary, "rust binary")?;
|
||||
|
||||
let upstream_dir = output_root.join("upstream");
|
||||
let rust_dir = output_root.join("rust");
|
||||
std::fs::create_dir_all(&upstream_dir)
|
||||
.with_context(|| format!("failed to create {}", upstream_dir.display()))?;
|
||||
std::fs::create_dir_all(&rust_dir)
|
||||
.with_context(|| format!("failed to create {}", rust_dir.display()))?;
|
||||
|
||||
let upstream_version = capture_stdout_lines(&upstream_binary, &["-v"])?;
|
||||
let upstream_help =
|
||||
redact_windows_home_paths(capture_stdout_lines(&upstream_binary, &["--help=#all"])?);
|
||||
let rust_version = capture_stdout_lines(&rust_binary, &["--version"])?;
|
||||
let rust_help =
|
||||
redact_windows_home_paths(capture_stdout_lines(&rust_binary, &["--help=#all"])?);
|
||||
|
||||
write_utf8_lines(&upstream_dir.join("version.txt"), &upstream_version)?;
|
||||
write_utf8_lines(&upstream_dir.join("help-all.txt"), &upstream_help)?;
|
||||
write_utf8_lines(&rust_dir.join("version.txt"), &rust_version)?;
|
||||
write_utf8_lines(&rust_dir.join("help-all.txt"), &rust_help)?;
|
||||
|
||||
let captured = captured_paths();
|
||||
let manifest = CompatManifest {
|
||||
generated_at_utc: utc_now_rfc3339()?,
|
||||
captured: captured.to_vec(),
|
||||
};
|
||||
let manifest_text = serde_json::to_string_pretty(&manifest)
|
||||
.context("failed to serialize compatibility manifest")?;
|
||||
write_utf8_text(&output_root.join("manifest.json"), &(manifest_text + "\n"))?;
|
||||
|
||||
let summary = CompatSummary {
|
||||
captured: summary_paths().to_vec(),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize compatibility summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the repository-local default upstream aria2 binary path.
|
||||
fn default_upstream_binary() -> PathBuf {
|
||||
crate::workspace::repo_root()
|
||||
.join("dist")
|
||||
.join("upstream")
|
||||
.join("aria2-1.37.0-win-64bit-build1")
|
||||
.join("aria2-1.37.0-win-64bit-build1")
|
||||
.join("aria2c.exe")
|
||||
}
|
||||
|
||||
/// Ensures an expected binary path exists.
|
||||
fn ensure_path_exists(path: &Path, label: &str) -> Result<()> {
|
||||
if path.is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("{label} not found: {}", path.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a Windows account name embedded in captured CLI help text.
|
||||
fn redact_windows_home_paths(lines: Vec<String>) -> Vec<String> {
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| redact_windows_home_path(&line))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replaces the first Windows home-directory account name in a line.
|
||||
fn redact_windows_home_path(line: &str) -> String {
|
||||
const PREFIX: &str = "C:/Users/";
|
||||
|
||||
let Some((leading, remainder)) = line.split_once(PREFIX) else {
|
||||
return line.to_owned();
|
||||
};
|
||||
let Some((_, trailing)) = remainder.split_once('/') else {
|
||||
return line.to_owned();
|
||||
};
|
||||
format!("{leading}{PREFIX}<user>/{trailing}")
|
||||
}
|
||||
|
||||
/// Returns the golden files produced by the capture workflow.
|
||||
const fn captured_paths() -> &'static [&'static str] {
|
||||
&[
|
||||
"upstream/version.txt",
|
||||
"upstream/help-all.txt",
|
||||
"rust/version.txt",
|
||||
"rust/help-all.txt",
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns all paths reported in the workflow summary.
|
||||
const fn summary_paths() -> &'static [&'static str] {
|
||||
&[
|
||||
"upstream/version.txt",
|
||||
"upstream/help-all.txt",
|
||||
"rust/version.txt",
|
||||
"rust/help-all.txt",
|
||||
"manifest.json",
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::redact_windows_home_path;
|
||||
|
||||
#[test]
|
||||
fn redacts_a_windows_home_path_in_captured_help() {
|
||||
assert_eq!(
|
||||
redact_windows_home_path("Default: C:/Users/alice/.netrc"),
|
||||
"Default: C:/Users/<user>/.netrc"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
use std::{
|
||||
env,
|
||||
ffi::{OsStr, OsString},
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitStatus, Stdio},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
cli::{DockerSmokeArgs, DockerSmokeLocalArgs, ExportLocalArgs},
|
||||
workspace::{
|
||||
load_workspace_metadata, repo_root, run_command, run_command_with_env, utc_now_rfc3339,
|
||||
workspace_relative_or_external, write_utf8_text,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Machine-readable manifest written beside a Docker image archive.
|
||||
struct DockerArchiveManifest {
|
||||
/// Package version used for the archive directory.
|
||||
version: String,
|
||||
/// Docker image tag exported by the workflow.
|
||||
tag: String,
|
||||
/// Docker image identifier reported by `docker image inspect`.
|
||||
image_id: String,
|
||||
/// Exported archive artifact metadata.
|
||||
archive: DockerArchiveArtifact,
|
||||
/// Path to the aggregate checksum list.
|
||||
checksum_list: String,
|
||||
/// UTC generation timestamp for the manifest.
|
||||
generated_at_utc: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Metadata for a Docker archive artifact.
|
||||
struct DockerArchiveArtifact {
|
||||
/// Archive file name.
|
||||
file_name: String,
|
||||
/// Full archive path.
|
||||
path: String,
|
||||
/// Hex-encoded SHA-256 digest.
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Summary printed after exporting a local Docker image.
|
||||
struct DockerExportSummary {
|
||||
#[serde(rename = "Version")]
|
||||
/// Package version used for the archive directory.
|
||||
version: String,
|
||||
#[serde(rename = "Tag")]
|
||||
/// Docker image tag exported by the workflow.
|
||||
tag: String,
|
||||
#[serde(rename = "ArchivePath")]
|
||||
/// Path to the exported Docker archive.
|
||||
archive_path: String,
|
||||
#[serde(rename = "ManifestPath")]
|
||||
/// Path to the generated manifest.
|
||||
manifest_path: String,
|
||||
#[serde(rename = "ChecksumList")]
|
||||
/// Path to the aggregate checksum list.
|
||||
checksum_list: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Summary printed after running the Docker container smoke test.
|
||||
struct DockerSmokeSummary {
|
||||
#[serde(rename = "Dockerfile")]
|
||||
/// Dockerfile used to build the image.
|
||||
dockerfile: String,
|
||||
#[serde(rename = "ComposeFile")]
|
||||
/// Compose file shipped with the image.
|
||||
compose_file: String,
|
||||
#[serde(rename = "VersionOutput")]
|
||||
/// Captured `aria2c --version` output.
|
||||
version_output: String,
|
||||
#[serde(rename = "RuntimeConfigPreview")]
|
||||
/// Captured generated runtime config.
|
||||
runtime_config_preview: String,
|
||||
#[serde(rename = "RpcResponsePreview")]
|
||||
/// Captured JSON-RPC probe response.
|
||||
rpc_response_preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Summary printed after running the shell-only Docker entrypoint smoke.
|
||||
struct DockerSmokeLocalSummary {
|
||||
#[serde(rename = "Entrypoint")]
|
||||
/// Entrypoint script exercised by the smoke.
|
||||
entrypoint: String,
|
||||
#[serde(rename = "RuntimeConfig")]
|
||||
/// Generated runtime config path.
|
||||
runtime_config: String,
|
||||
#[serde(rename = "BaseConfig")]
|
||||
/// Materialized base config path.
|
||||
base_config: String,
|
||||
#[serde(rename = "CapturedArgv")]
|
||||
/// Arguments captured by the fake aria2 binary.
|
||||
captured_argv: Vec<String>,
|
||||
}
|
||||
|
||||
/// Exports a local Docker image archive and checksum manifest.
|
||||
pub fn run_export_local(args: &ExportLocalArgs) -> Result<()> {
|
||||
let metadata = load_workspace_metadata()?;
|
||||
let repo_root = repo_root();
|
||||
let dockerfile = dockerfile_path(&repo_root);
|
||||
let output_root = args
|
||||
.output_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| repo_root.join("dist").join("docker"));
|
||||
|
||||
if args.build {
|
||||
build_image(&args.tag, &dockerfile, &repo_root)?;
|
||||
}
|
||||
|
||||
assert_image_available(&args.tag)?;
|
||||
|
||||
let version_dir = output_root.join(format!("v{}", metadata.package_version));
|
||||
std::fs::create_dir_all(&version_dir)
|
||||
.with_context(|| format!("failed to create {}", version_dir.display()))?;
|
||||
|
||||
let archive_base_name = format!("aria2-rust-pro-docker-v{}", metadata.package_version);
|
||||
let archive_name = format!("{archive_base_name}.tar");
|
||||
let archive_path = version_dir.join(&archive_name);
|
||||
if archive_path.exists() {
|
||||
std::fs::remove_file(&archive_path)
|
||||
.with_context(|| format!("failed to remove {}", archive_path.display()))?;
|
||||
}
|
||||
|
||||
run_command(
|
||||
"docker",
|
||||
&[
|
||||
os("save"),
|
||||
os("-o"),
|
||||
archive_path.clone().into_os_string(),
|
||||
os(&args.tag),
|
||||
],
|
||||
)?;
|
||||
|
||||
let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?;
|
||||
let archive_hash = sha256_hex(&archive_path)?;
|
||||
let image_id = capture_command_stdout(
|
||||
"docker",
|
||||
&[
|
||||
os("image"),
|
||||
os("inspect"),
|
||||
os("--format"),
|
||||
os("{{.Id}}"),
|
||||
os(&args.tag),
|
||||
],
|
||||
)?;
|
||||
let archive_path_text = workspace_relative_or_external(&archive_path);
|
||||
let checksum_list_path = workspace_relative_or_external(&checksum_list);
|
||||
let manifest_path = version_dir.join(format!("{archive_base_name}.manifest.json"));
|
||||
let manifest_path_text = workspace_relative_or_external(&manifest_path);
|
||||
let manifest = DockerArchiveManifest {
|
||||
version: metadata.package_version.clone(),
|
||||
tag: args.tag.clone(),
|
||||
image_id: trim_owned(&image_id),
|
||||
archive: DockerArchiveArtifact {
|
||||
file_name: archive_name,
|
||||
path: archive_path_text.clone(),
|
||||
sha256: archive_hash,
|
||||
},
|
||||
checksum_list: checksum_list_path.clone(),
|
||||
generated_at_utc: utc_now_rfc3339()?,
|
||||
};
|
||||
let manifest_text = serde_json::to_string_pretty(&manifest)
|
||||
.context("failed to serialize docker export manifest")?;
|
||||
write_utf8_text(&manifest_path, &(manifest_text + "\n"))?;
|
||||
|
||||
let summary = DockerExportSummary {
|
||||
version: metadata.package_version,
|
||||
tag: args.tag.clone(),
|
||||
archive_path: archive_path_text,
|
||||
manifest_path: manifest_path_text,
|
||||
checksum_list: checksum_list_path,
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize docker export summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the Docker image smoke workflow against a live container.
|
||||
pub fn run_smoke(args: &DockerSmokeArgs) -> Result<()> {
|
||||
let repo_root = repo_root();
|
||||
let dockerfile = dockerfile_path(&repo_root);
|
||||
let compose_file = compose_file_path(&repo_root);
|
||||
|
||||
if args.build {
|
||||
build_image(&args.tag, &dockerfile, &repo_root)?;
|
||||
}
|
||||
|
||||
let version_output = capture_command_stdout(
|
||||
"docker",
|
||||
&[
|
||||
os("run"),
|
||||
os("--rm"),
|
||||
os(&args.tag),
|
||||
os("aria2c"),
|
||||
os("--version"),
|
||||
],
|
||||
)?;
|
||||
|
||||
let container_id = start_smoke_container(args)?;
|
||||
let _guard = DockerContainerGuard::new(container_id.clone());
|
||||
|
||||
sleep(Duration::from_secs(3));
|
||||
docker_exec_ok(&container_id, &["test", "-f", "/config/aria2.conf"])
|
||||
.context("container did not materialize /config/aria2.conf")?;
|
||||
|
||||
let runtime_config = capture_command_stdout(
|
||||
"docker",
|
||||
&[
|
||||
os("exec"),
|
||||
os(&container_id),
|
||||
os("cat"),
|
||||
os("/run/aria2-rust-pro/aria2.generated.conf"),
|
||||
],
|
||||
)
|
||||
.context("docker exec runtime config read failed")?;
|
||||
|
||||
verify_container_special_mode(&container_id, &runtime_config, &args.special_mode)?;
|
||||
|
||||
let rpc_payload = format!(
|
||||
"{{\"jsonrpc\":\"2.0\",\"id\":\"smoke\",\"method\":\"aria2.getVersion\",\"params\":[\"token:{}\"]}}",
|
||||
args.rpc_secret
|
||||
);
|
||||
let rpc_response = capture_command_stdout_with_stdin(
|
||||
"docker",
|
||||
&[
|
||||
os("exec"),
|
||||
os("-i"),
|
||||
os(&container_id),
|
||||
os("curl"),
|
||||
os("-fsS"),
|
||||
os("-H"),
|
||||
os("Content-Type: application/json"),
|
||||
os("--data-binary"),
|
||||
os("@-"),
|
||||
os("http://127.0.0.1:6800/jsonrpc"),
|
||||
],
|
||||
&rpc_payload,
|
||||
)
|
||||
.context("docker exec rpc probe failed")?;
|
||||
|
||||
ensure_contains(
|
||||
&rpc_response,
|
||||
"\"version\"",
|
||||
"rpc probe did not return version payload",
|
||||
)?;
|
||||
verify_container_runtime_config(&runtime_config)?;
|
||||
let dockerfile_path = workspace_relative_or_external(&dockerfile);
|
||||
let compose_file_path = workspace_relative_or_external(&compose_file);
|
||||
|
||||
let summary = DockerSmokeSummary {
|
||||
dockerfile: dockerfile_path,
|
||||
compose_file: compose_file_path,
|
||||
version_output: trim_owned(&version_output),
|
||||
runtime_config_preview: trim_owned(&redact_runtime_config(&runtime_config)),
|
||||
rpc_response_preview: trim_owned(&rpc_response),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize docker smoke summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts the container used by the Docker smoke test and returns its id.
|
||||
fn start_smoke_container(args: &DockerSmokeArgs) -> Result<String> {
|
||||
let port_mapping = format!("{}:6800", args.host_rpc_port);
|
||||
let container_id = capture_command_stdout(
|
||||
"docker",
|
||||
&[
|
||||
os("run"),
|
||||
os("-d"),
|
||||
os("-e"),
|
||||
os(&format!("RPC_SECRET={}", args.rpc_secret)),
|
||||
os("-e"),
|
||||
os("UPDATE_TRACKERS=false"),
|
||||
os("-e"),
|
||||
os(&format!("SPECIAL_MODE={}", args.special_mode)),
|
||||
os("-p"),
|
||||
os(&port_mapping),
|
||||
os(&args.tag),
|
||||
],
|
||||
)?;
|
||||
let container_id = trim_owned(&container_id);
|
||||
if container_id.is_empty() {
|
||||
bail!("docker run -d returned an empty container id");
|
||||
}
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
/// Verifies special-mode side effects inside the smoke-test container.
|
||||
fn verify_container_special_mode(
|
||||
container_id: &str,
|
||||
runtime_config: &str,
|
||||
special_mode: &str,
|
||||
) -> Result<()> {
|
||||
if special_mode == "move" {
|
||||
docker_exec_ok(container_id, &["test", "-f", "/config/script/move.sh"])
|
||||
.context("container did not materialize move special-mode script")?;
|
||||
ensure_contains(
|
||||
runtime_config,
|
||||
"on-download-complete=/config/script/move.sh",
|
||||
"runtime config did not apply SPECIAL_MODE=move hook override",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies common generated runtime config snippets for the container smoke.
|
||||
fn verify_container_runtime_config(runtime_config: &str) -> Result<()> {
|
||||
ensure_contains(
|
||||
runtime_config,
|
||||
"bt-tracker=",
|
||||
"runtime config did not seed bundled bt-tracker snapshot",
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs the Docker entrypoint smoke locally with a fake aria2 binary.
|
||||
pub fn run_smoke_local(_args: &DockerSmokeLocalArgs) -> Result<()> {
|
||||
let workspace = SmokeLocalWorkspace::new()?;
|
||||
let shell = find_posix_shell()
|
||||
.ok_or_else(|| anyhow!("no POSIX shell was found for docker smoke-local"))?;
|
||||
|
||||
run_shell_script(&shell, SMOKE_LOCAL_SCRIPT, &workspace.envs)?;
|
||||
|
||||
let downloads_dir_unix = capture_unix_path(&shell, "DOWNLOAD_DIR_WIN", &workspace.envs)?;
|
||||
let config_dir_unix = capture_unix_path(&shell, "CONFIG_DIR_WIN", &workspace.envs)?;
|
||||
|
||||
let runtime_config = workspace.runtime_dir.join("aria2.generated.conf");
|
||||
validate_smoke_local_outputs(&runtime_config, &workspace.capture_path)?;
|
||||
let runtime_config_text = std::fs::read_to_string(&runtime_config)
|
||||
.with_context(|| format!("failed to read {}", runtime_config.display()))?;
|
||||
let captured_argv_text = std::fs::read_to_string(&workspace.capture_path)
|
||||
.with_context(|| format!("failed to read {}", workspace.capture_path.display()))?;
|
||||
let captured_argv = captured_argv_text
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let base_config = workspace.config_dir.join("aria2.conf");
|
||||
let session_file = workspace.config_dir.join("aria2.session");
|
||||
validate_smoke_local_files(&workspace.config_dir, &base_config, &session_file)?;
|
||||
validate_smoke_local_config(&runtime_config_text, &downloads_dir_unix, &config_dir_unix)?;
|
||||
validate_smoke_local_argv(&captured_argv)?;
|
||||
let entrypoint_path = workspace_relative_or_external(&workspace.entrypoint);
|
||||
let runtime_config_path = workspace_relative_or_external(&runtime_config);
|
||||
let base_config_path = workspace_relative_or_external(&base_config);
|
||||
|
||||
let summary = DockerSmokeLocalSummary {
|
||||
entrypoint: entrypoint_path,
|
||||
runtime_config: runtime_config_path,
|
||||
base_config: base_config_path,
|
||||
captured_argv,
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize docker smoke-local summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Temporary workspace for the local Docker entrypoint smoke.
|
||||
struct SmokeLocalWorkspace {
|
||||
/// Temporary directory whose lifetime owns all smoke files.
|
||||
_work_root: tempfile::TempDir,
|
||||
/// Entrypoint script under test.
|
||||
entrypoint: PathBuf,
|
||||
/// Temporary configuration directory.
|
||||
config_dir: PathBuf,
|
||||
/// Temporary runtime directory.
|
||||
runtime_dir: PathBuf,
|
||||
/// File where the fake aria2 binary records argv.
|
||||
capture_path: PathBuf,
|
||||
/// Environment variables passed to the smoke shell.
|
||||
envs: Vec<(&'static str, OsString)>,
|
||||
}
|
||||
|
||||
impl SmokeLocalWorkspace {
|
||||
/// Creates the temporary workspace and fake aria2 executable.
|
||||
fn new() -> Result<Self> {
|
||||
let repo_root = repo_root();
|
||||
let entrypoint = repo_root.join("docker").join("entrypoint.sh");
|
||||
let defaults_dir = repo_root.join("docker").join("defaults");
|
||||
let work_root =
|
||||
tempfile::tempdir().context("failed to create temporary smoke-local root")?;
|
||||
let config_dir = work_root.path().join("config");
|
||||
let downloads_dir = work_root.path().join("downloads");
|
||||
let runtime_dir = work_root.path().join("run");
|
||||
let bin_dir = work_root.path().join("bin");
|
||||
for directory in [&config_dir, &downloads_dir, &runtime_dir, &bin_dir] {
|
||||
std::fs::create_dir_all(directory)
|
||||
.with_context(|| format!("failed to create {}", directory.display()))?;
|
||||
}
|
||||
|
||||
let capture_path = work_root.path().join("captured-argv.txt");
|
||||
let fake_aria2 = bin_dir.join("aria2c");
|
||||
write_utf8_text(
|
||||
&fake_aria2,
|
||||
"#!/usr/bin/env bash\nset -eu\nprintf '%s\\n' \"$@\" > \"$CAPTURE_PATH\"\n",
|
||||
)?;
|
||||
let envs = smoke_local_envs(
|
||||
&entrypoint,
|
||||
&defaults_dir,
|
||||
&config_dir,
|
||||
&downloads_dir,
|
||||
&runtime_dir,
|
||||
&fake_aria2,
|
||||
&capture_path,
|
||||
);
|
||||
Ok(Self {
|
||||
_work_root: work_root,
|
||||
entrypoint,
|
||||
config_dir,
|
||||
runtime_dir,
|
||||
capture_path,
|
||||
envs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that the smoke script wrote its primary outputs.
|
||||
fn validate_smoke_local_outputs(runtime_config: &Path, capture_path: &Path) -> Result<()> {
|
||||
if !runtime_config.is_file() {
|
||||
bail!("entrypoint did not generate runtime config");
|
||||
}
|
||||
if !capture_path.is_file() {
|
||||
bail!("fake aria2 binary did not capture argv");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies files materialized by the local Docker entrypoint smoke.
|
||||
fn validate_smoke_local_files(
|
||||
config_dir: &Path,
|
||||
base_config: &Path,
|
||||
session_file: &Path,
|
||||
) -> Result<()> {
|
||||
if !base_config.is_file() {
|
||||
bail!("entrypoint did not materialize base aria2.conf");
|
||||
}
|
||||
if !session_file.is_file() {
|
||||
bail!("entrypoint did not materialize aria2.session");
|
||||
}
|
||||
let move_script = config_dir.join("script").join("move.sh");
|
||||
if !move_script.is_file() {
|
||||
bail!("entrypoint did not materialize move special-mode script");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies expected generated runtime config snippets.
|
||||
fn validate_smoke_local_config(
|
||||
runtime_config_text: &str,
|
||||
downloads_dir_unix: &str,
|
||||
config_dir_unix: &str,
|
||||
) -> Result<()> {
|
||||
let session_file_unix = format!("{config_dir_unix}/aria2.session");
|
||||
for snippet in [
|
||||
format!("dir={downloads_dir_unix}"),
|
||||
format!("input-file={session_file_unix}"),
|
||||
format!("save-session={session_file_unix}"),
|
||||
"save-session-interval=60".to_owned(),
|
||||
"enable-rpc=true".to_owned(),
|
||||
"rpc-listen-port=16800".to_owned(),
|
||||
"listen-port=51413".to_owned(),
|
||||
"dht-listen-port=51413".to_owned(),
|
||||
"disable-ipv6=false".to_owned(),
|
||||
"rpc-secret=smoke-secret".to_owned(),
|
||||
"rpc-listen-all=true".to_owned(),
|
||||
"disk-cache=32M".to_owned(),
|
||||
format!("on-download-complete={config_dir_unix}/script/move.sh"),
|
||||
] {
|
||||
ensure_contains(
|
||||
runtime_config_text,
|
||||
&snippet,
|
||||
"runtime config missing expected snippet",
|
||||
)?;
|
||||
}
|
||||
ensure_contains(
|
||||
runtime_config_text,
|
||||
"bt-tracker=",
|
||||
"runtime config did not seed bundled bt-tracker snapshot",
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies the fake aria2 binary observed the expected argument flow.
|
||||
fn validate_smoke_local_argv(captured_argv: &[String]) -> Result<()> {
|
||||
if captured_argv.len() < 3 {
|
||||
bail!("captured argv is incomplete: {captured_argv:?}");
|
||||
}
|
||||
if captured_argv
|
||||
.first()
|
||||
.is_none_or(|value| value != "--enable-rpc")
|
||||
{
|
||||
bail!("captured argv missing --enable-rpc bootstrap: {captured_argv:?}");
|
||||
}
|
||||
if captured_argv
|
||||
.get(1)
|
||||
.is_none_or(|value| !value.starts_with("--conf-path="))
|
||||
{
|
||||
bail!("captured argv missing conf-path: {captured_argv:?}");
|
||||
}
|
||||
if captured_argv
|
||||
.last()
|
||||
.is_none_or(|value| value != "--version")
|
||||
{
|
||||
bail!("captured argv did not preserve passthrough args: {captured_argv:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POSIX shell script used by the local Docker entrypoint smoke.
|
||||
const SMOKE_LOCAL_SCRIPT: &str = r#"
|
||||
set -eu
|
||||
to_unix_path() {
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
cygpath -u "$1"
|
||||
else
|
||||
printf '%s\n' "$1"
|
||||
fi
|
||||
}
|
||||
entrypoint=$(to_unix_path "$ENTRYPOINT_WIN")
|
||||
defaults_dir=$(to_unix_path "$DEFAULTS_DIR_WIN")
|
||||
config_dir=$(to_unix_path "$CONFIG_DIR_WIN")
|
||||
downloads_dir=$(to_unix_path "$DOWNLOAD_DIR_WIN")
|
||||
runtime_dir=$(to_unix_path "$RUNTIME_DIR_WIN")
|
||||
fake_aria2=$(to_unix_path "$FAKE_ARIA2_WIN")
|
||||
capture_path=$(to_unix_path "$CAPTURE_PATH_WIN")
|
||||
chmod +x "$fake_aria2"
|
||||
CONFIG_DIR="$config_dir" \
|
||||
DOWNLOAD_DIR="$downloads_dir" \
|
||||
RUNTIME_DIR="$runtime_dir" \
|
||||
DEFAULTS_DIR="$defaults_dir" \
|
||||
ARIA2_BIN="$fake_aria2" \
|
||||
CAPTURE_PATH="$capture_path" \
|
||||
RPC_SECRET='smoke-secret' \
|
||||
RPC_PORT='16800' \
|
||||
DISK_CACHE='32M' \
|
||||
LISTEN_PORT='51413' \
|
||||
IPV6_MODE='true' \
|
||||
UPDATE_TRACKERS='false' \
|
||||
CUSTOM_TRACKER_URL='https://example.invalid/trackers.txt' \
|
||||
SPECIAL_MODE='move' \
|
||||
UMASK_SET='027' \
|
||||
"$entrypoint" --version
|
||||
"#;
|
||||
|
||||
/// Drop guard that force-removes a smoke-test container.
|
||||
struct DockerContainerGuard {
|
||||
/// Container id returned by `docker run -d`.
|
||||
container_id: String,
|
||||
}
|
||||
|
||||
impl DockerContainerGuard {
|
||||
/// Builds a container cleanup guard.
|
||||
const fn new(container_id: String) -> Self {
|
||||
Self { container_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DockerContainerGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", &self.container_id])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the repository Dockerfile path.
|
||||
fn dockerfile_path(repo_root: &Path) -> PathBuf {
|
||||
repo_root.join("docker").join("Dockerfile")
|
||||
}
|
||||
|
||||
/// Returns the repository Docker Compose file path.
|
||||
fn compose_file_path(repo_root: &Path) -> PathBuf {
|
||||
repo_root.join("docker").join("docker-compose.yml")
|
||||
}
|
||||
|
||||
/// Builds the Docker image used by export and smoke workflows.
|
||||
fn build_image(tag: &str, dockerfile: &Path, repo_root: &Path) -> Result<()> {
|
||||
let envs = if env::var_os("DOCKER_BUILDKIT").is_none() {
|
||||
// Synology Docker 24 can leave buildx sessions idle on this full release build.
|
||||
vec![("DOCKER_BUILDKIT", os("0"))]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
run_command_with_env(
|
||||
"docker",
|
||||
&[
|
||||
os("build"),
|
||||
os("-t"),
|
||||
os(tag),
|
||||
os("-f"),
|
||||
dockerfile.into(),
|
||||
repo_root.into(),
|
||||
],
|
||||
&envs,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Ensures the requested Docker image tag exists locally.
|
||||
fn assert_image_available(tag: &str) -> Result<()> {
|
||||
run_command("docker", &[os("image"), os("inspect"), os(tag)]).map_err(|_| {
|
||||
anyhow!("docker image `{tag}` is not available; build it first or pass --build")
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a Docker exec command that is expected to succeed.
|
||||
fn docker_exec_ok(container_id: &str, tail: &[&str]) -> Result<()> {
|
||||
let mut args = vec![os("exec"), os(container_id)];
|
||||
args.extend(tail.iter().map(|value| os(value)));
|
||||
run_command("docker", &args)
|
||||
}
|
||||
|
||||
/// Runs a command and captures UTF-8 stdout.
|
||||
fn capture_command_stdout(program: &str, args: &[OsString]) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch `{program}`"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"`{program}` failed with status {}: {}",
|
||||
render_exit_status(output.status),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
String::from_utf8(output.stdout).context("command emitted non-UTF-8 stdout")
|
||||
}
|
||||
|
||||
/// Runs a command with UTF-8 stdin and captures UTF-8 stdout.
|
||||
fn capture_command_stdout_with_stdin(
|
||||
program: &str,
|
||||
args: &[OsString],
|
||||
stdin_text: &str,
|
||||
) -> Result<String> {
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to launch `{program}`"))?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("failed to open stdin for `{program}`"))?;
|
||||
stdin
|
||||
.write_all(stdin_text.as_bytes())
|
||||
.with_context(|| format!("failed to write stdin for `{program}`"))?;
|
||||
drop(stdin);
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.with_context(|| format!("failed to wait for `{program}`"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"`{program}` failed with status {}: {}",
|
||||
render_exit_status(output.status),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
String::from_utf8(output.stdout).context("command emitted non-UTF-8 stdout")
|
||||
}
|
||||
|
||||
/// Builds Windows-path environment variables consumed by the local smoke script.
|
||||
fn smoke_local_envs(
|
||||
entrypoint: &Path,
|
||||
defaults_dir: &Path,
|
||||
config_dir: &Path,
|
||||
downloads_dir: &Path,
|
||||
runtime_dir: &Path,
|
||||
fake_aria2: &Path,
|
||||
capture_path: &Path,
|
||||
) -> Vec<(&'static str, OsString)> {
|
||||
vec![
|
||||
("ENTRYPOINT_WIN", entrypoint.as_os_str().to_os_string()),
|
||||
("DEFAULTS_DIR_WIN", defaults_dir.as_os_str().to_os_string()),
|
||||
("CONFIG_DIR_WIN", config_dir.as_os_str().to_os_string()),
|
||||
("DOWNLOAD_DIR_WIN", downloads_dir.as_os_str().to_os_string()),
|
||||
("RUNTIME_DIR_WIN", runtime_dir.as_os_str().to_os_string()),
|
||||
("FAKE_ARIA2_WIN", fake_aria2.as_os_str().to_os_string()),
|
||||
("CAPTURE_PATH_WIN", capture_path.as_os_str().to_os_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Captures a workspace path converted for the selected POSIX shell.
|
||||
fn capture_unix_path(
|
||||
shell: &Path,
|
||||
env_name: &'static str,
|
||||
envs: &[(&str, OsString)],
|
||||
) -> Result<String> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -eu
|
||||
to_unix_path() {{
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
cygpath -u "$1"
|
||||
else
|
||||
printf '%s\n' "$1"
|
||||
fi
|
||||
}}
|
||||
to_unix_path "${{{env_name}}}"
|
||||
"#
|
||||
);
|
||||
Ok(capture_shell_stdout(shell, &script, envs)?
|
||||
.trim()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Runs a POSIX shell script with the provided environment variables.
|
||||
fn run_shell_script(shell: &Path, script: &str, envs: &[(&str, OsString)]) -> Result<()> {
|
||||
let mut command = Command::new(shell);
|
||||
command.arg("-lc").arg(script);
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
let status = command
|
||||
.status()
|
||||
.with_context(|| format!("failed to launch {}", shell.display()))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"{} failed with status {}",
|
||||
shell.display(),
|
||||
render_exit_status(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a POSIX shell script and captures UTF-8 stdout.
|
||||
fn capture_shell_stdout(shell: &Path, script: &str, envs: &[(&str, OsString)]) -> Result<String> {
|
||||
let mut command = Command::new(shell);
|
||||
command.arg("-lc").arg(script);
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
let output = command
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch {}", shell.display()))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"{} failed with status {}: {}",
|
||||
shell.display(),
|
||||
render_exit_status(output.status),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
String::from_utf8(output.stdout).context("shell emitted non-UTF-8 stdout")
|
||||
}
|
||||
|
||||
/// Locates a POSIX shell suitable for the local Docker entrypoint smoke.
|
||||
fn find_posix_shell() -> Option<PathBuf> {
|
||||
let names: &[&str] = if cfg!(windows) {
|
||||
&["bash.exe", "bash", "sh.exe", "sh"]
|
||||
} else {
|
||||
&["bash", "sh"]
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path_var) = env::var_os("PATH") {
|
||||
for directory in env::split_paths(&path_var) {
|
||||
for name in names {
|
||||
let candidate = directory.join(name);
|
||||
if candidate.is_file() {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg!(windows) {
|
||||
for candidate in [
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files\Git\usr\bin\bash.exe",
|
||||
r"C:\Program Files\Git\bin\sh.exe",
|
||||
r"C:\Program Files\Git\usr\bin\sh.exe",
|
||||
] {
|
||||
let path = PathBuf::from(candidate);
|
||||
if path.is_file() {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
choose_posix_shell_candidate(&candidates)
|
||||
}
|
||||
|
||||
/// Chooses the best available POSIX shell candidate for local smoke checks.
|
||||
fn choose_posix_shell_candidate(candidates: &[PathBuf]) -> Option<PathBuf> {
|
||||
candidates
|
||||
.iter()
|
||||
.find(|candidate| !is_windowsapps_bash(candidate))
|
||||
.cloned()
|
||||
.or_else(|| candidates.first().cloned())
|
||||
}
|
||||
|
||||
/// Returns whether the shell path resolves to the `WindowsApps` WSL launcher.
|
||||
fn is_windowsapps_bash(path: &Path) -> bool {
|
||||
let normalized = path
|
||||
.to_string_lossy()
|
||||
.replace('/', "\\")
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("\\windowsapps\\bash.exe")
|
||||
}
|
||||
|
||||
/// Ensures a captured text payload contains an expected snippet.
|
||||
fn ensure_contains(text: &str, needle: &str, context: &str) -> Result<()> {
|
||||
if text.contains(needle) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("{context}: {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes per-artifact and aggregate SHA-256 checksum files.
|
||||
fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result<PathBuf> {
|
||||
let mut archive_candidates = std::fs::read_dir(output_directory)
|
||||
.with_context(|| format!("failed to read {}", output_directory.display()))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file() && is_archive_path(path))
|
||||
.collect::<Vec<_>>();
|
||||
archive_candidates.sort();
|
||||
|
||||
for path in paths {
|
||||
let hash = sha256_hex(path)?;
|
||||
let file_name = file_name_string_lossy(path)?;
|
||||
let line = format!("{hash} *{file_name}\n");
|
||||
write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?;
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(archive_candidates.len());
|
||||
for path in archive_candidates {
|
||||
let hash = sha256_hex(&path)?;
|
||||
let file_name = file_name_string_lossy(&path)?;
|
||||
lines.push(format!("{hash} *{file_name}"));
|
||||
}
|
||||
|
||||
let checksum_list_path = output_directory.join("SHA256SUMS.txt");
|
||||
write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?;
|
||||
Ok(checksum_list_path)
|
||||
}
|
||||
|
||||
/// Computes a hex-encoded SHA-256 digest for a file.
|
||||
fn sha256_hex(path: &Path) -> Result<String> {
|
||||
let mut file =
|
||||
File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = Box::new([0_u8; 64 * 1024]);
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer[..])
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Returns whether a path looks like a Docker archive.
|
||||
fn is_archive_path(path: &Path) -> bool {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(OsStr::to_string_lossy)
|
||||
.unwrap_or_default();
|
||||
file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar")
|
||||
}
|
||||
|
||||
/// Trims surrounding whitespace and returns an owned string.
|
||||
fn trim_owned(text: &str) -> String {
|
||||
text.trim().to_owned()
|
||||
}
|
||||
|
||||
/// Redacts generated runtime config lines that may carry local secrets.
|
||||
fn redact_runtime_config(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("rpc-secret=") {
|
||||
let indent = line.strip_suffix(trimmed).unwrap_or_default();
|
||||
format!("{indent}rpc-secret=<redacted>")
|
||||
} else {
|
||||
line.to_owned()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Renders a process exit status for diagnostics.
|
||||
fn render_exit_status(status: ExitStatus) -> String {
|
||||
status
|
||||
.code()
|
||||
.map_or_else(|| String::from("signal"), |code| code.to_string())
|
||||
}
|
||||
|
||||
/// Returns the trailing file name as UTF-8 text for archive reporting.
|
||||
fn file_name_string_lossy(path: &Path) -> Result<String> {
|
||||
path.file_name()
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"archive path did not include a file name: {}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.map(|name| OsStr::to_string_lossy(name).into_owned())
|
||||
}
|
||||
|
||||
/// Converts a string slice into an owned OS string for command arguments.
|
||||
fn os(value: &str) -> OsString {
|
||||
OsString::from(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{choose_posix_shell_candidate, redact_runtime_config};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn prefers_git_bash_over_windowsapps_bash_on_windows() {
|
||||
let candidates = vec![
|
||||
PathBuf::from(r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe"),
|
||||
PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"),
|
||||
];
|
||||
|
||||
let selected = choose_posix_shell_candidate(&candidates);
|
||||
|
||||
assert_eq!(
|
||||
selected,
|
||||
Some(PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_windowsapps_bash_when_no_better_shell_exists() {
|
||||
let candidates = vec![PathBuf::from(
|
||||
r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe",
|
||||
)];
|
||||
|
||||
let selected = choose_posix_shell_candidate(&candidates);
|
||||
|
||||
assert_eq!(
|
||||
selected,
|
||||
Some(PathBuf::from(
|
||||
r"C:\Users\example\AppData\Local\Microsoft\WindowsApps\bash.exe",
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_runtime_config_rpc_secret_preview() {
|
||||
let config = "enable-rpc=true\nrpc-secret=super-secret\n rpc-secret=indented\n";
|
||||
|
||||
let redacted = redact_runtime_config(config);
|
||||
|
||||
assert_eq!(
|
||||
redacted,
|
||||
"enable-rpc=true\nrpc-secret=<redacted>\n rpc-secret=<redacted>"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Cargo-native workflow entrypoints for `aria2-rust-pro`.
|
||||
#![expect(
|
||||
unreachable_pub,
|
||||
clippy::indexing_slicing,
|
||||
clippy::integer_division,
|
||||
clippy::large_stack_arrays,
|
||||
clippy::redundant_pub_crate,
|
||||
clippy::too_many_lines,
|
||||
reason = "xtask is an internal workflow binary; product-facing runtime crates keep the stricter public-surface and implementation discipline"
|
||||
)]
|
||||
|
||||
/// `clap` command-line model for the cargo-native workflow entrypoint.
|
||||
mod cli;
|
||||
/// Compatibility-oriented maintenance workflows.
|
||||
mod compat;
|
||||
/// Local Docker export and smoke workflows.
|
||||
mod docker;
|
||||
/// Performance collection and same-host comparison workflows.
|
||||
mod perf;
|
||||
/// Release packaging and version-smoke workflows.
|
||||
mod release;
|
||||
/// Strict local quality-sweep workflows.
|
||||
mod testing;
|
||||
/// Shared workspace discovery and command helpers.
|
||||
mod workspace;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::{
|
||||
Cli, CompatSubcommand, DockerSubcommand, PerfSubcommand, ReleaseSubcommand, TestingSubcommand,
|
||||
TopLevelCommand,
|
||||
};
|
||||
|
||||
/// Runs the `xtask` entrypoint and converts structured failures into a non-zero exit code.
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches the requested top-level `xtask` workflow.
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
TopLevelCommand::Compat(command) => match command.command {
|
||||
CompatSubcommand::CaptureCliGoldens(args) => compat::run_capture_cli_goldens(&args),
|
||||
},
|
||||
TopLevelCommand::Docker(command) => match command.command {
|
||||
DockerSubcommand::ExportLocal(args) => docker::run_export_local(&args),
|
||||
DockerSubcommand::Smoke(args) => docker::run_smoke(&args),
|
||||
DockerSubcommand::SmokeLocal(args) => docker::run_smoke_local(&args),
|
||||
},
|
||||
TopLevelCommand::Perf(command) => match command.command {
|
||||
PerfSubcommand::CollectLocalComparison(args) => {
|
||||
perf::run_collect_local_comparison(&args)
|
||||
}
|
||||
},
|
||||
TopLevelCommand::Release(command) => match command.command {
|
||||
ReleaseSubcommand::SmokeVersion(args) => release::run_release_smoke_version(&args),
|
||||
ReleaseSubcommand::PackageLocal(args) => release::run_package_local(&args),
|
||||
},
|
||||
TopLevelCommand::Testing(command) => match command.command {
|
||||
TestingSubcommand::StrictSweep(args) => testing::run_strict_sweep(&args),
|
||||
},
|
||||
}
|
||||
}
|
||||
+1622
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,610 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
fs::File,
|
||||
io::{BufWriter, Read},
|
||||
path::{Path, PathBuf},
|
||||
process::ExitStatus,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::TempDir;
|
||||
use walkdir::WalkDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use crate::{
|
||||
cli::{PackageLocalArgs, ReleaseSmokeVersionArgs},
|
||||
workspace::{
|
||||
archive_extension_for_target, default_binary_path, host_triple, load_workspace_metadata,
|
||||
root_manifest_path, run_command, utc_now_rfc3339, workspace_relative_or_external,
|
||||
write_utf8_text,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Summary printed after validating a release binary version.
|
||||
struct ReleaseSmokeSummary {
|
||||
#[serde(rename = "BinaryPath")]
|
||||
/// Binary that was executed for the smoke check.
|
||||
binary_path: String,
|
||||
#[serde(rename = "HostTriple")]
|
||||
/// Build host target triple.
|
||||
host_triple: String,
|
||||
#[serde(rename = "TargetTriple")]
|
||||
/// Release artifact target triple.
|
||||
target_triple: String,
|
||||
#[serde(rename = "Version")]
|
||||
/// Expected package version.
|
||||
version: String,
|
||||
#[serde(rename = "VersionOutput")]
|
||||
/// Captured `--version` output.
|
||||
version_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Machine-readable manifest written beside a release archive.
|
||||
struct ReleaseArchiveManifest {
|
||||
/// Package version in the archive.
|
||||
version: String,
|
||||
/// Build host target triple.
|
||||
host_triple: String,
|
||||
/// Release artifact target triple.
|
||||
target_triple: String,
|
||||
/// Packaged archive metadata.
|
||||
archive: ManifestArtifact,
|
||||
/// Source binary metadata.
|
||||
binary: ManifestArtifact,
|
||||
/// Path to the aggregate checksum list.
|
||||
checksum_list: String,
|
||||
/// Version smoke result included in the package manifest.
|
||||
version_smoke: VersionSmokeManifest,
|
||||
/// Files included in the staged archive.
|
||||
included_files: Vec<IncludedFile>,
|
||||
/// UTC generation timestamp for the manifest.
|
||||
generated_at_utc: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// File artifact metadata embedded in release manifests.
|
||||
struct ManifestArtifact {
|
||||
/// Artifact file name.
|
||||
file_name: String,
|
||||
/// Full artifact path.
|
||||
path: String,
|
||||
/// Hex-encoded SHA-256 digest.
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Captured release version-smoke output.
|
||||
struct VersionSmokeManifest {
|
||||
/// Whether the smoke check was intentionally skipped.
|
||||
skipped: bool,
|
||||
/// Captured output lines from the smoke check.
|
||||
output: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// File included in the staged release archive.
|
||||
struct IncludedFile {
|
||||
#[serde(rename = "RelativePath")]
|
||||
/// Archive-relative file path.
|
||||
relative_path: String,
|
||||
#[serde(rename = "Size")]
|
||||
/// File size in bytes.
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Summary printed after creating a local release package.
|
||||
struct PackageSummary {
|
||||
#[serde(rename = "Version")]
|
||||
/// Package version in the archive.
|
||||
version: String,
|
||||
#[serde(rename = "HostTriple")]
|
||||
/// Build host target triple.
|
||||
host_triple: String,
|
||||
#[serde(rename = "TargetTriple")]
|
||||
/// Release artifact target triple.
|
||||
target_triple: String,
|
||||
#[serde(rename = "SourceBinary")]
|
||||
/// Binary copied into the archive.
|
||||
source_binary: String,
|
||||
#[serde(rename = "ArchivePath")]
|
||||
/// Path to the generated archive.
|
||||
archive_path: String,
|
||||
#[serde(rename = "ManifestPath")]
|
||||
/// Path to the generated manifest.
|
||||
manifest_path: String,
|
||||
#[serde(rename = "ChecksumList")]
|
||||
/// Path to the aggregate checksum list.
|
||||
checksum_list: String,
|
||||
}
|
||||
|
||||
/// Runs the release binary version smoke workflow.
|
||||
pub fn run_release_smoke_version(args: &ReleaseSmokeVersionArgs) -> Result<()> {
|
||||
let metadata = load_workspace_metadata()?;
|
||||
let host_triple = host_triple()?;
|
||||
let target_triple = args
|
||||
.target_triple
|
||||
.clone()
|
||||
.unwrap_or_else(|| host_triple.clone());
|
||||
|
||||
if args.build {
|
||||
invoke_release_build(&target_triple, &host_triple)?;
|
||||
}
|
||||
|
||||
let binary_path = args
|
||||
.binary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple));
|
||||
let version_output = invoke_version_smoke(&binary_path, &metadata.package_version)?;
|
||||
let binary_path_text = workspace_relative_or_external(&binary_path);
|
||||
|
||||
let summary = ReleaseSmokeSummary {
|
||||
binary_path: binary_path_text,
|
||||
host_triple,
|
||||
target_triple,
|
||||
version: metadata.package_version,
|
||||
version_output: version_output.join("\n"),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize release smoke summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a local release archive and manifest from a compiled binary.
|
||||
pub fn run_package_local(args: &PackageLocalArgs) -> Result<()> {
|
||||
let metadata = load_workspace_metadata()?;
|
||||
let host_triple = host_triple()?;
|
||||
let target_triple = args
|
||||
.target_triple
|
||||
.clone()
|
||||
.unwrap_or_else(|| host_triple.clone());
|
||||
let output_root = args
|
||||
.output_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| metadata.workspace_root.join("dist").join("release"));
|
||||
|
||||
if args.build {
|
||||
invoke_release_build(&target_triple, &host_triple)?;
|
||||
}
|
||||
|
||||
let source_binary = args
|
||||
.source_binary
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple));
|
||||
ensure_file_exists(&source_binary, "release packaging could not find binary")?;
|
||||
|
||||
let artifact_base_name = format!(
|
||||
"aria2-rust-pro-v{}-{}",
|
||||
metadata.package_version, target_triple
|
||||
);
|
||||
let archive_extension = archive_extension_for_target(&target_triple);
|
||||
let version_dir = output_root.join(format!("v{}", metadata.package_version));
|
||||
std::fs::create_dir_all(&version_dir)
|
||||
.with_context(|| format!("failed to create {}", version_dir.display()))?;
|
||||
|
||||
let version_output = if args.skip_version_smoke {
|
||||
Vec::new()
|
||||
} else {
|
||||
invoke_version_smoke(&source_binary, &metadata.package_version)?
|
||||
};
|
||||
|
||||
let stage = StageDirectory::new(&artifact_base_name)?;
|
||||
let binary_name = file_name_os_string(&source_binary)?;
|
||||
std::fs::copy(&source_binary, stage.stage_root.join(&binary_name))
|
||||
.with_context(|| format!("failed to stage {}", source_binary.display()))?;
|
||||
|
||||
if target_triple.contains("windows")
|
||||
&& let Some(pdb_path) = find_windows_pdb_path(&source_binary)
|
||||
{
|
||||
let pdb_name = file_name_os_string(&pdb_path)?;
|
||||
std::fs::copy(&pdb_path, stage.stage_root.join(&pdb_name))
|
||||
.with_context(|| format!("failed to stage {}", pdb_path.display()))?;
|
||||
}
|
||||
|
||||
add_optional_file(
|
||||
&metadata.workspace_root.join("README.md"),
|
||||
&stage.stage_root,
|
||||
Path::new("README.md"),
|
||||
)?;
|
||||
add_optional_file(
|
||||
&metadata
|
||||
.workspace_root
|
||||
.join("docs")
|
||||
.join("release")
|
||||
.join("README.md"),
|
||||
&stage.stage_root,
|
||||
Path::new("docs")
|
||||
.join("release")
|
||||
.join("README.md")
|
||||
.as_path(),
|
||||
)?;
|
||||
|
||||
let archive_path = version_dir.join(format!("{artifact_base_name}{archive_extension}"));
|
||||
if archive_extension == ".zip" {
|
||||
create_zip_archive(&stage.stage_root, &archive_path)?;
|
||||
} else {
|
||||
create_tar_gz_archive(&stage.stage_root, &archive_path)?;
|
||||
}
|
||||
|
||||
let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?;
|
||||
let binary_hash = sha256_hex(&source_binary)?;
|
||||
let archive_hash = sha256_hex(&archive_path)?;
|
||||
let manifest_path = version_dir.join(format!("{artifact_base_name}.manifest.json"));
|
||||
let included_files = collect_included_files(&stage.stage_root)?;
|
||||
let archive_path_text = workspace_relative_or_external(&archive_path);
|
||||
let source_binary_text = workspace_relative_or_external(&source_binary);
|
||||
let checksum_list_text = workspace_relative_or_external(&checksum_list);
|
||||
let manifest_path_text = workspace_relative_or_external(&manifest_path);
|
||||
let manifest = ReleaseArchiveManifest {
|
||||
version: metadata.package_version.clone(),
|
||||
host_triple: host_triple.clone(),
|
||||
target_triple: target_triple.clone(),
|
||||
archive: ManifestArtifact {
|
||||
file_name: file_name_string(&archive_path)?,
|
||||
path: archive_path_text.clone(),
|
||||
sha256: archive_hash,
|
||||
},
|
||||
binary: ManifestArtifact {
|
||||
file_name: file_name_string(&source_binary)?,
|
||||
path: source_binary_text.clone(),
|
||||
sha256: binary_hash,
|
||||
},
|
||||
checksum_list: checksum_list_text.clone(),
|
||||
version_smoke: VersionSmokeManifest {
|
||||
skipped: args.skip_version_smoke,
|
||||
output: version_output,
|
||||
},
|
||||
included_files,
|
||||
generated_at_utc: utc_now_rfc3339()?,
|
||||
};
|
||||
let manifest_text =
|
||||
serde_json::to_string_pretty(&manifest).context("failed to serialize release manifest")?;
|
||||
write_utf8_text(&manifest_path, &(manifest_text + "\n"))?;
|
||||
|
||||
let summary = PackageSummary {
|
||||
version: metadata.package_version,
|
||||
host_triple,
|
||||
target_triple,
|
||||
source_binary: source_binary_text,
|
||||
archive_path: archive_path_text,
|
||||
manifest_path: manifest_path_text,
|
||||
checksum_list: checksum_list_text,
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.context("failed to serialize release package summary")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes a binary and validates its aria2-compatible version banner.
|
||||
fn invoke_version_smoke(binary_path: &Path, expected_version: &str) -> Result<Vec<String>> {
|
||||
ensure_file_exists(binary_path, "version smoke could not find binary")?;
|
||||
let output = std::process::Command::new(binary_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch {}", binary_path.display()))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"version smoke failed with status {}: {}",
|
||||
render_exit_status(output.status),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.with_context(|| format!("{} emitted non-UTF-8 stdout", binary_path.display()))?;
|
||||
let lines = stdout.lines().map(str::to_owned).collect::<Vec<_>>();
|
||||
let first_line = lines
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("version smoke output was empty"))?;
|
||||
let expected_compat_line = "aria2 version 1.37.0";
|
||||
if !first_line.starts_with(expected_compat_line) {
|
||||
bail!("version smoke output did not start with `{expected_compat_line}`: {first_line}");
|
||||
}
|
||||
|
||||
let expected_package_line = format!("Rust rewrite package: aria2-rust-pro {expected_version}");
|
||||
let package_line = lines
|
||||
.iter()
|
||||
.find(|line| line.starts_with("Rust rewrite package:"))
|
||||
.ok_or_else(|| {
|
||||
anyhow!("version smoke output did not include a Rust rewrite package line")
|
||||
})?;
|
||||
if package_line != &expected_package_line {
|
||||
bail!("version smoke package line did not match `{expected_package_line}`: {package_line}");
|
||||
}
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Invokes the release build for the requested target triple.
|
||||
fn invoke_release_build(target_triple: &str, host_triple: &str) -> Result<()> {
|
||||
let manifest_path = root_manifest_path();
|
||||
let mut args = vec![
|
||||
os("+nightly"),
|
||||
os("build"),
|
||||
os("-Zbuild-std=std,panic_abort"),
|
||||
os("--manifest-path"),
|
||||
manifest_path.into_os_string(),
|
||||
os("-p"),
|
||||
os("aria2-rust-pro-cli"),
|
||||
os("--bin"),
|
||||
os("aria2-rust-pro"),
|
||||
os("--release"),
|
||||
];
|
||||
if target_triple != host_triple {
|
||||
args.push(os("--target"));
|
||||
args.push(os(target_triple));
|
||||
}
|
||||
run_command("cargo", &args)
|
||||
}
|
||||
|
||||
/// Temporary release staging directory.
|
||||
struct StageDirectory {
|
||||
/// Temporary directory whose lifetime keeps the staging tree alive.
|
||||
_temp_dir: TempDir,
|
||||
/// Root directory containing staged archive contents.
|
||||
stage_root: PathBuf,
|
||||
}
|
||||
|
||||
impl StageDirectory {
|
||||
/// Creates a new temporary staging directory for one artifact.
|
||||
fn new(artifact_base_name: &str) -> Result<Self> {
|
||||
let temp_dir =
|
||||
TempDir::new().context("failed to create temporary release staging directory")?;
|
||||
let stage_root = temp_dir.path().join(artifact_base_name);
|
||||
std::fs::create_dir_all(&stage_root)
|
||||
.with_context(|| format!("failed to create {}", stage_root.display()))?;
|
||||
Ok(Self {
|
||||
_temp_dir: temp_dir,
|
||||
stage_root,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures an expected release input file exists.
|
||||
fn ensure_file_exists(path: &Path, label: &str) -> Result<()> {
|
||||
if path.is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("{label}: {}", path.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the optional Windows PDB companion for a release binary.
|
||||
fn find_windows_pdb_path(binary_path: &Path) -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
let mut direct = binary_path.to_path_buf();
|
||||
direct.set_extension("pdb");
|
||||
candidates.push(direct);
|
||||
let alt = binary_path
|
||||
.parent()
|
||||
.map(|parent| parent.join("aria2_rust_pro.pdb"));
|
||||
if let Some(path) = alt {
|
||||
candidates.push(path);
|
||||
}
|
||||
candidates.into_iter().find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
/// Copies an optional repository file into the staging tree.
|
||||
fn add_optional_file(
|
||||
source_path: &Path,
|
||||
destination_root: &Path,
|
||||
relative_path: &Path,
|
||||
) -> Result<bool> {
|
||||
if !source_path.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let destination_path = destination_root.join(relative_path);
|
||||
if let Some(parent) = destination_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(source_path, &destination_path).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
source_path.display(),
|
||||
destination_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Creates a zip archive from the staged release tree.
|
||||
fn create_zip_archive(stage_root: &Path, archive_path: &Path) -> Result<()> {
|
||||
if archive_path.exists() {
|
||||
std::fs::remove_file(archive_path)
|
||||
.with_context(|| format!("failed to remove {}", archive_path.display()))?;
|
||||
}
|
||||
|
||||
let archive_root = file_name_string(stage_root)?;
|
||||
let file = File::create(archive_path)
|
||||
.with_context(|| format!("failed to create {}", archive_path.display()))?;
|
||||
let writer = BufWriter::new(file);
|
||||
let mut zip = zip::ZipWriter::new(writer);
|
||||
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
for entry in WalkDir::new(stage_root).sort_by_file_name() {
|
||||
let entry = entry.with_context(|| format!("failed to walk {}", stage_root.display()))?;
|
||||
let path = entry.path();
|
||||
let relative = path.strip_prefix(stage_root).with_context(|| {
|
||||
format!(
|
||||
"failed to strip {} from {}",
|
||||
stage_root.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let archive_name = if relative.as_os_str().is_empty() {
|
||||
archive_root.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{archive_root}/{}",
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
)
|
||||
};
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
if !relative.as_os_str().is_empty() {
|
||||
zip.add_directory(format!("{archive_name}/"), options)
|
||||
.with_context(|| {
|
||||
format!("failed to add directory {} to zip", path.display())
|
||||
})?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
zip.start_file(&archive_name, options)
|
||||
.with_context(|| format!("failed to add file {} to zip", path.display()))?;
|
||||
let mut input =
|
||||
File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
||||
std::io::copy(&mut input, &mut zip)
|
||||
.with_context(|| format!("failed to copy {} into zip", path.display()))?;
|
||||
}
|
||||
|
||||
zip.finish().context("failed to finalize zip archive")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a gzip-compressed tar archive from the staged release tree.
|
||||
fn create_tar_gz_archive(stage_root: &Path, archive_path: &Path) -> Result<()> {
|
||||
if archive_path.exists() {
|
||||
std::fs::remove_file(archive_path)
|
||||
.with_context(|| format!("failed to remove {}", archive_path.display()))?;
|
||||
}
|
||||
|
||||
let file = File::create(archive_path)
|
||||
.with_context(|| format!("failed to create {}", archive_path.display()))?;
|
||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut builder = tar::Builder::new(encoder);
|
||||
let archive_root = stage_root.file_name().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"stage root did not include a directory name: {}",
|
||||
stage_root.display()
|
||||
)
|
||||
})?;
|
||||
builder
|
||||
.append_dir_all(archive_root, stage_root)
|
||||
.with_context(|| format!("failed to package {}", stage_root.display()))?;
|
||||
builder
|
||||
.finish()
|
||||
.context("failed to finalize tar.gz archive")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes per-artifact and aggregate SHA-256 checksum files.
|
||||
fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result<PathBuf> {
|
||||
let mut archive_candidates = std::fs::read_dir(output_directory)
|
||||
.with_context(|| format!("failed to read {}", output_directory.display()))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file() && is_archive_path(path))
|
||||
.collect::<Vec<_>>();
|
||||
archive_candidates.sort();
|
||||
|
||||
for path in paths {
|
||||
let hash = sha256_hex(path)?;
|
||||
let file_name = file_name_string(path)?;
|
||||
let line = format!("{hash} *{file_name}\n");
|
||||
write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?;
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(archive_candidates.len());
|
||||
for path in archive_candidates {
|
||||
let hash = sha256_hex(&path)?;
|
||||
let file_name = file_name_string(&path)?;
|
||||
lines.push(format!("{hash} *{file_name}"));
|
||||
}
|
||||
|
||||
let checksum_list_path = output_directory.join("SHA256SUMS.txt");
|
||||
write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?;
|
||||
Ok(checksum_list_path)
|
||||
}
|
||||
|
||||
/// Computes a hex-encoded SHA-256 digest for a file.
|
||||
fn sha256_hex(path: &Path) -> Result<String> {
|
||||
let mut file =
|
||||
File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = Box::new([0_u8; 64 * 1024]);
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer[..])
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Collects files included in the staged release tree.
|
||||
fn collect_included_files(stage_root: &Path) -> Result<Vec<IncludedFile>> {
|
||||
WalkDir::new(stage_root)
|
||||
.sort_by_file_name()
|
||||
.into_iter()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.map(|entry| {
|
||||
let relative = entry.path().strip_prefix(stage_root).with_context(|| {
|
||||
format!(
|
||||
"failed to strip {} from {}",
|
||||
stage_root.display(),
|
||||
entry.path().display()
|
||||
)
|
||||
})?;
|
||||
let metadata = entry.metadata().with_context(|| {
|
||||
format!("failed to read metadata for {}", entry.path().display())
|
||||
})?;
|
||||
Ok(IncludedFile {
|
||||
relative_path: relative.to_string_lossy().replace('\\', "/"),
|
||||
size: metadata.len(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the trailing file name as UTF-8 text for manifest entries.
|
||||
fn file_name_string(path: &Path) -> Result<String> {
|
||||
path.file_name()
|
||||
.ok_or_else(|| anyhow!("path did not include a file name: {}", path.display()))
|
||||
.map(|name| OsStr::to_string_lossy(name).into_owned())
|
||||
}
|
||||
|
||||
/// Returns the trailing file name as an owned OS string.
|
||||
fn file_name_os_string(path: &Path) -> Result<OsString> {
|
||||
path.file_name()
|
||||
.ok_or_else(|| anyhow!("path did not include a file name: {}", path.display()))
|
||||
.map(OsStr::to_os_string)
|
||||
}
|
||||
|
||||
/// Converts a string slice into an owned OS string for command arguments.
|
||||
fn os(value: &str) -> OsString {
|
||||
OsString::from(value)
|
||||
}
|
||||
|
||||
/// Renders a process exit status for release diagnostics.
|
||||
fn render_exit_status(status: ExitStatus) -> String {
|
||||
status
|
||||
.code()
|
||||
.map_or_else(|| String::from("signal"), |code| code.to_string())
|
||||
}
|
||||
|
||||
/// Returns whether a path looks like a release archive.
|
||||
fn is_archive_path(path: &Path) -> bool {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_default();
|
||||
file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar")
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{
|
||||
cli::{ReleaseSmokeVersionArgs, StrictSweepArgs},
|
||||
release::run_release_smoke_version,
|
||||
workspace::{repo_root, root_manifest_path, run_command_with_env},
|
||||
};
|
||||
|
||||
/// Runs the local strict quality sweep used before release or handoff.
|
||||
pub fn run_strict_sweep(args: &StrictSweepArgs) -> Result<()> {
|
||||
let repo_root = repo_root();
|
||||
let manifest_path = root_manifest_path();
|
||||
let deny_config_path = repo_root.join("deny.toml");
|
||||
let commands = vec![
|
||||
SweepCommand::new(
|
||||
"fmt",
|
||||
Some(repo_root.join("target-gate-fmt")),
|
||||
vec![
|
||||
os("fmt"),
|
||||
os("--all"),
|
||||
os("--check"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
],
|
||||
),
|
||||
SweepCommand::new(
|
||||
"check",
|
||||
Some(repo_root.join("target-gate-check")),
|
||||
vec![
|
||||
os("check"),
|
||||
os("--workspace"),
|
||||
os("--all-targets"),
|
||||
os("--all-features"),
|
||||
os("--locked"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
],
|
||||
),
|
||||
SweepCommand::new(
|
||||
"nextest",
|
||||
Some(repo_root.join("target-gate-nextest")),
|
||||
vec![
|
||||
os("nextest"),
|
||||
os("run"),
|
||||
os("--workspace"),
|
||||
os("--all-targets"),
|
||||
os("--all-features"),
|
||||
os("--locked"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
],
|
||||
),
|
||||
SweepCommand::new(
|
||||
"clippy",
|
||||
Some(repo_root.join("target-gate-clippy")),
|
||||
vec![
|
||||
os("clippy"),
|
||||
os("--workspace"),
|
||||
os("--all-targets"),
|
||||
os("--all-features"),
|
||||
os("--locked"),
|
||||
os("--no-deps"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
os("--"),
|
||||
os("-D"),
|
||||
os("warnings"),
|
||||
os("-D"),
|
||||
os("clippy::pedantic"),
|
||||
os("-D"),
|
||||
os("clippy::nursery"),
|
||||
],
|
||||
),
|
||||
SweepCommand::new(
|
||||
"udeps",
|
||||
Some(repo_root.join("target-gate-udeps")),
|
||||
vec![
|
||||
os("+nightly"),
|
||||
os("udeps"),
|
||||
os("--workspace"),
|
||||
os("--all-targets"),
|
||||
os("--all-features"),
|
||||
os("--locked"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
],
|
||||
),
|
||||
SweepCommand::new(
|
||||
"deny",
|
||||
None,
|
||||
vec![
|
||||
os("deny"),
|
||||
os("--manifest-path"),
|
||||
path_arg(&manifest_path),
|
||||
os("--locked"),
|
||||
os("check"),
|
||||
os("--config"),
|
||||
path_arg(&deny_config_path),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
for command in commands {
|
||||
println!("==> {}", command.name);
|
||||
let envs = build_envs(command.target_dir.as_deref(), command.name == "deny");
|
||||
run_command_with_env("cargo", &command.args, &envs, Some(&repo_root))?;
|
||||
}
|
||||
|
||||
if args.include_release_smoke {
|
||||
println!("==> release-smoke");
|
||||
run_release_smoke_version(&ReleaseSmokeVersionArgs {
|
||||
build: true,
|
||||
target_triple: None,
|
||||
binary_path: None,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A cargo command executed as part of the strict sweep.
|
||||
struct SweepCommand {
|
||||
/// Short label printed before executing the command.
|
||||
name: &'static str,
|
||||
/// Optional target directory isolating build artifacts.
|
||||
target_dir: Option<PathBuf>,
|
||||
/// Arguments passed to cargo.
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
impl SweepCommand {
|
||||
/// Builds a sweep command descriptor.
|
||||
const fn new(name: &'static str, target_dir: Option<PathBuf>, args: Vec<OsString>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
target_dir,
|
||||
args,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds environment overrides for an individual sweep command.
|
||||
fn build_envs(target_dir: Option<&Path>, cargo_deny: bool) -> Vec<(&'static str, OsString)> {
|
||||
let mut envs = vec![("CARGO_INCREMENTAL", os("0"))];
|
||||
if let Some(target_dir) = target_dir {
|
||||
envs.push(("CARGO_TARGET_DIR", target_dir.as_os_str().to_os_string()));
|
||||
}
|
||||
if cargo_deny {
|
||||
envs.extend([
|
||||
("GIT_CONFIG_COUNT", os("1")),
|
||||
("GIT_CONFIG_KEY_0", os("http.sslbackend")),
|
||||
("GIT_CONFIG_VALUE_0", os("openssl")),
|
||||
]);
|
||||
}
|
||||
envs
|
||||
}
|
||||
|
||||
/// Converts a string literal into an owned OS argument.
|
||||
fn os(value: &str) -> OsString {
|
||||
OsString::from(value)
|
||||
}
|
||||
|
||||
/// Converts the root manifest path into a fresh cargo argument.
|
||||
fn path_arg(path: &Path) -> OsString {
|
||||
path.as_os_str().to_os_string()
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitStatus},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use cargo_metadata::MetadataCommand;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Shared workspace metadata discovered from `cargo metadata`.
|
||||
pub struct WorkspaceMetadata {
|
||||
/// Absolute repository root of the workspace.
|
||||
pub workspace_root: PathBuf,
|
||||
/// Resolved Cargo target directory for the workspace.
|
||||
pub target_directory: PathBuf,
|
||||
/// Current package version of the CLI package.
|
||||
pub package_version: String,
|
||||
/// Canonical binary name produced by the workspace.
|
||||
pub binary_name: String,
|
||||
}
|
||||
|
||||
/// Returns the repository root that contains the `xtask` crate.
|
||||
pub fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.expect("xtask should live directly under the workspace root")
|
||||
}
|
||||
|
||||
/// Returns the absolute path to the workspace root manifest.
|
||||
pub fn root_manifest_path() -> PathBuf {
|
||||
repo_root().join("Cargo.toml")
|
||||
}
|
||||
|
||||
/// Renders a path for public manifests without exposing the local workspace.
|
||||
pub fn workspace_relative_or_external(path: &Path) -> String {
|
||||
path.strip_prefix(repo_root()).map_or_else(
|
||||
|_| {
|
||||
path.file_name().map_or_else(
|
||||
|| "<external>".to_owned(),
|
||||
|name| format!("<external>/{}", name.to_string_lossy()),
|
||||
)
|
||||
},
|
||||
|relative| relative.to_string_lossy().replace('\\', "/"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads shared workspace metadata used by multiple `xtask` workflows.
|
||||
pub fn load_workspace_metadata() -> Result<WorkspaceMetadata> {
|
||||
let manifest_path = root_manifest_path();
|
||||
let metadata = MetadataCommand::new()
|
||||
.manifest_path(&manifest_path)
|
||||
.no_deps()
|
||||
.exec()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to run cargo metadata for {}",
|
||||
manifest_path.display()
|
||||
)
|
||||
})?;
|
||||
let cli_package = metadata
|
||||
.packages
|
||||
.iter()
|
||||
.find(|package| package.name == "aria2-rust-pro-cli")
|
||||
.ok_or_else(|| anyhow!("cargo metadata did not return aria2-rust-pro-cli"))?;
|
||||
|
||||
Ok(WorkspaceMetadata {
|
||||
workspace_root: metadata.workspace_root.into_std_path_buf(),
|
||||
target_directory: metadata.target_directory.into_std_path_buf(),
|
||||
package_version: cli_package.version.to_string(),
|
||||
binary_name: String::from("aria2-rust-pro"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the Rust host target triple reported by the current toolchain.
|
||||
pub fn host_triple() -> Result<String> {
|
||||
let output = Command::new("rustc")
|
||||
.arg("-vV")
|
||||
.output()
|
||||
.context("failed to launch rustc -vV")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"rustc -vV failed with status {}",
|
||||
render_exit_status(output.status)
|
||||
);
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).context("rustc -vV emitted non-UTF-8 stdout")?;
|
||||
stdout
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("host:").map(str::trim))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| anyhow!("rustc -vV did not report a host triple"))
|
||||
}
|
||||
|
||||
/// Returns the binary filename for a target triple, including `.exe` on Windows.
|
||||
pub fn binary_name_for_target(binary_name: &str, target_triple: &str) -> String {
|
||||
if target_triple.contains("windows") {
|
||||
format!("{binary_name}.exe")
|
||||
} else {
|
||||
String::from(binary_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the release archive extension for a target triple.
|
||||
pub fn archive_extension_for_target(target_triple: &str) -> &'static str {
|
||||
if target_triple.contains("windows") {
|
||||
".zip"
|
||||
} else {
|
||||
".tar.gz"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default release binary path for a target triple.
|
||||
pub fn default_binary_path(
|
||||
metadata: &WorkspaceMetadata,
|
||||
target_triple: &str,
|
||||
host_triple: &str,
|
||||
) -> PathBuf {
|
||||
let binary_name = binary_name_for_target(&metadata.binary_name, target_triple);
|
||||
if target_triple == host_triple {
|
||||
metadata.target_directory.join("release").join(binary_name)
|
||||
} else {
|
||||
metadata
|
||||
.target_directory
|
||||
.join(target_triple)
|
||||
.join("release")
|
||||
.join(binary_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command and returns an error with a rendered command line when it fails.
|
||||
pub fn run_command(program: &str, args: &[OsString]) -> Result<()> {
|
||||
let rendered = render_command(program, args);
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("failed to launch `{rendered}`"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"`{rendered}` failed with status {}",
|
||||
render_exit_status(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command with additional environment variables and reports contextual failures.
|
||||
pub fn run_command_with_env(
|
||||
program: &str,
|
||||
args: &[OsString],
|
||||
envs: &[(&str, OsString)],
|
||||
working_dir: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let rendered = render_command(program, args);
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
if let Some(working_dir) = working_dir {
|
||||
command.current_dir(working_dir);
|
||||
}
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
let status = command
|
||||
.status()
|
||||
.with_context(|| format!("failed to launch `{rendered}`"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"`{rendered}` failed with status {}",
|
||||
render_exit_status(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures UTF-8 stdout lines from a process that is expected to succeed.
|
||||
pub fn capture_stdout_lines(program: &Path, args: &[&str]) -> Result<Vec<String>> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch {}", program.display()))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"{} failed with status {}: {}",
|
||||
program.display(),
|
||||
render_exit_status(output.status),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.with_context(|| format!("{} emitted non-UTF-8 stdout", program.display()))?;
|
||||
Ok(stdout.lines().map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
/// Writes newline-delimited UTF-8 text lines to a file, creating parent directories first.
|
||||
pub fn write_utf8_lines(path: &Path, lines: &[String]) -> Result<()> {
|
||||
let mut text = lines.join("\n");
|
||||
text.push('\n');
|
||||
write_utf8_text(path, &text)
|
||||
}
|
||||
|
||||
/// Writes UTF-8 text to a file, creating parent directories first.
|
||||
pub fn write_utf8_text(path: &Path, text: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
/// Returns the current UTC timestamp formatted as `RFC 3339`.
|
||||
pub fn utc_now_rfc3339() -> Result<String> {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.context("failed to format current UTC timestamp")
|
||||
}
|
||||
|
||||
/// Renders a shell-style command preview for error messages.
|
||||
fn render_command(program: &str, args: &[OsString]) -> String {
|
||||
let rendered_args = args
|
||||
.iter()
|
||||
.map(|arg| quote_os(arg.as_os_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if rendered_args.is_empty() {
|
||||
String::from(program)
|
||||
} else {
|
||||
format!("{program} {rendered_args}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Quotes an OS string for readable command rendering when it contains spaces.
|
||||
fn quote_os(value: &OsStr) -> String {
|
||||
let text = value.to_string_lossy();
|
||||
if text.contains(' ') {
|
||||
format!("\"{text}\"")
|
||||
} else {
|
||||
text.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a process exit status for workspace command failures.
|
||||
fn render_exit_status(status: ExitStatus) -> String {
|
||||
status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_owned(), |code| code.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Path, repo_root, workspace_relative_or_external};
|
||||
|
||||
#[test]
|
||||
fn renders_workspace_paths_relative_and_external_paths_anonymous() {
|
||||
assert_eq!(
|
||||
workspace_relative_or_external(&repo_root().join("Cargo.toml")),
|
||||
"Cargo.toml"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_relative_or_external(Path::new("outside.bin")),
|
||||
"<external>/outside.bin"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user