Files
aria2-rust-pro/xtask/src/release.rs
T

615 lines
21 KiB
Rust

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, 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 = path_display_string(&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 = path_display_string(&archive_path);
let source_binary_text = path_display_string(&source_binary);
let checksum_list_text = path_display_string(&checksum_list);
let manifest_path_text = path_display_string(&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)
}
/// Renders a path display adapter into an owned string.
fn path_display_string(path: &Path) -> String {
format!("{}", path.display())
}
/// 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")
}