feat(release): 支持自定义打包产物目录

release 现在会在设置 EMOD_ARTIFACTS_DIR 时将 ZIP 输出到按项目名隔离的 artifacts 子目录,未设置时保持写入项目目录。

新增单元测试覆盖默认输出和环境变量输出两条路径,避免发布产物污染源项目或路径回退失效。
This commit is contained in:
2026-05-09 22:01:47 +08:00
parent 933aa295b2
commit 405cdaab81

View File

@@ -1,4 +1,5 @@
use std::{ use std::{
env,
fs::{self, File}, fs::{self, File},
io::{Read, Write}, io::{Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -14,6 +15,8 @@ use crate::error::{CliError, Result};
use crate::utils::file; use crate::utils::file;
use crate::{entity, entity::project::ReleaseInfo}; use crate::{entity, entity::project::ReleaseInfo};
const ARTIFACTS_DIR_ENV: &str = "EMOD_ARTIFACTS_DIR";
pub fn execute(args: &ReleaseArgs) { pub fn execute(args: &ReleaseArgs) {
if let Err(e) = run_release(args) { if let Err(e) = run_release(args) {
eprintln!("❌ 组件打包失败: {}", e); eprintln!("❌ 组件打包失败: {}", e);
@@ -34,11 +37,7 @@ fn run_release(args: &ReleaseArgs) -> Result<()> {
Ok(()) Ok(())
} }
fn release( fn release(args: &ReleaseArgs, project_dir: &PathBuf, release_info: &ReleaseInfo) -> Result<()> {
args: &ReleaseArgs,
project_dir: &PathBuf,
release_info: &ReleaseInfo,
) -> Result<()> {
let new_version = calculate_version(&args.ver, args.pin, &release_info.behavior_version)?; let new_version = calculate_version(&args.ver, args.pin, &release_info.behavior_version)?;
let version_value = Value::Array(new_version.iter().map(|v| Value::from(*v)).collect()); let version_value = Value::Array(new_version.iter().map(|v| Value::from(*v)).collect());
@@ -50,16 +49,12 @@ fn release(
let version_str = format!("{}.{}.{}", new_version[0], new_version[1], new_version[2]); let version_str = format!("{}.{}.{}", new_version[0], new_version[1], new_version[2]);
let output_path = package_project(&project_dir, &pack_dirs, &version_str)?; let output_path = package_project(&project_dir, &pack_dirs, &version_str)?;
let display_path = output_path.display().to_string().replace("\\", "/");
println!("📦 打包完成: {}", output_path.replace("\\", "/")); println!("📦 打包完成: {}", display_path);
Ok(()) Ok(())
} }
fn calculate_version( fn calculate_version(version: &Option<String>, pin: bool, current: &[u32]) -> Result<Vec<u32>> {
version: &Option<String>,
pin: bool,
current: &[u32],
) -> Result<Vec<u32>> {
if let Some(ver_str) = version { if let Some(ver_str) = version {
ver_str ver_str
.split(".") .split(".")
@@ -116,11 +111,7 @@ fn ensure_pack_ready(label: &str, pack_dir: &Path, identifier: &str) -> Result<(
Ok(()) Ok(())
} }
fn update_versions( fn update_versions(project_dir: &PathBuf, version: &Value, pack_dirs: &PackDirs) -> Result<()> {
project_dir: &PathBuf,
version: &Value,
pack_dirs: &PackDirs,
) -> Result<()> {
update_pack_json(&project_dir, &version)?; update_pack_json(&project_dir, &version)?;
update_manifest_json(&version, pack_dirs)?; update_manifest_json(&version, pack_dirs)?;
Ok(()) Ok(())
@@ -155,11 +146,7 @@ fn update_manifest_json(version: &Value, pack_dirs: &PackDirs) -> Result<()> {
Ok(()) Ok(())
} }
fn package_project( fn package_project(project_dir: &PathBuf, pack_dirs: &PackDirs, version: &str) -> Result<PathBuf> {
project_dir: &PathBuf,
pack_dirs: &PackDirs,
version: &str,
) -> Result<String> {
let canonical_project_dir = project_dir let canonical_project_dir = project_dir
.canonicalize() .canonicalize()
.map_err(|e| file::io_error("解析项目目录", project_dir, e))?; .map_err(|e| file::io_error("解析项目目录", project_dir, e))?;
@@ -178,15 +165,25 @@ fn package_project(
canonical_project_dir.display() canonical_project_dir.display()
)) ))
})?; })?;
let output_path = format!(
"{}/{}_release_{}.zip", let output_dir =
project_dir.display(), match env::var_os(ARTIFACTS_DIR_ENV).filter(|value| !value.as_os_str().is_empty()) {
project_name, Some(artifacts_dir) => {
version let output_dir = PathBuf::from(artifacts_dir).join(project_name);
fs::create_dir_all(&output_dir)
.map_err(|e| file::io_error("创建打包输出目录", &output_dir, e))?;
println!(
"📦 使用 {} 指定的打包输出目录: {}",
ARTIFACTS_DIR_ENV,
output_dir.display().to_string().replace("\\", "/")
); );
let output_pathbuf = PathBuf::from(&output_path); output_dir
}
None => project_dir.clone(),
};
let output_path = output_dir.join(format!("{project_name}_release_{version}.zip"));
let file = fs::File::create(&output_path) let file = fs::File::create(&output_path)
.map_err(|e| file::io_error("创建打包文件", &output_pathbuf, e))?; .map_err(|e| file::io_error("创建打包文件", &output_path, e))?;
let mut zip = zip::ZipWriter::new(file); let mut zip = zip::ZipWriter::new(file);
let ignore = EmodIgnore::load(project_dir)?; let ignore = EmodIgnore::load(project_dir)?;
@@ -276,8 +273,7 @@ fn add_world_pack_files(
} }
zip.start_file(name, options)?; zip.start_file(name, options)?;
let mut f = let mut f = File::open(&path).map_err(|e| file::io_error("打开打包文件", &path, e))?;
File::open(&path).map_err(|e| file::io_error("打开打包文件", &path, e))?;
f.read_to_end(&mut buffer) f.read_to_end(&mut buffer)
.map_err(|e| file::io_error("读取打包文件", &path, e))?; .map_err(|e| file::io_error("读取打包文件", &path, e))?;
zip.write_all(&buffer)?; zip.write_all(&buffer)?;
@@ -342,8 +338,7 @@ fn add_with_package(
continue; continue;
} }
zip.start_file(&path_str, options)?; zip.start_file(&path_str, options)?;
let mut f = let mut f = File::open(path).map_err(|e| file::io_error("打开打包文件", path, e))?;
File::open(path).map_err(|e| file::io_error("打开打包文件", path, e))?;
f.read_to_end(&mut buffer) f.read_to_end(&mut buffer)
.map_err(|e| file::io_error("读取打包文件", path, e))?; .map_err(|e| file::io_error("读取打包文件", path, e))?;
zip.write_all(&buffer)?; zip.write_all(&buffer)?;
@@ -554,10 +549,7 @@ fn normalize_path(path: &Path) -> String {
/// review tooling rejects as "中文或特殊符号". Always normalize to '/'. /// review tooling rejects as "中文或特殊符号". Always normalize to '/'.
fn zip_entry_path(relative_path: &Path) -> Result<String> { fn zip_entry_path(relative_path: &Path) -> Result<String> {
let s = relative_path.to_str().ok_or_else(|| { let s = relative_path.to_str().ok_or_else(|| {
crate::error::CliError::InvalidData(format!( crate::error::CliError::InvalidData(format!("{:?} 不是有效的 UTF-8 路径", relative_path))
"{:?} 不是有效的 UTF-8 路径",
relative_path
))
})?; })?;
Ok(s.replace('\\', "/")) Ok(s.replace('\\', "/"))
} }
@@ -614,9 +606,12 @@ mod tests {
use super::*; use super::*;
use std::{ use std::{
fs, fs,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
static ARTIFACTS_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test] #[test]
fn preflight_pack_dirs_reports_missing_behavior_pack() { fn preflight_pack_dirs_reports_missing_behavior_pack() {
let project_dir = temp_project_dir(); let project_dir = temp_project_dir();
@@ -675,6 +670,66 @@ mod tests {
assert_eq!(v, vec![1, 2, 3]); assert_eq!(v, vec![1, 2, 3]);
} }
#[test]
fn package_writes_to_project_dir_when_env_unset() {
let project_dir = temp_project_dir();
let release_info = release_info();
write_file(
&project_dir.join("behavior_pack_abcdefgh/scripts/main.py"),
"main",
);
write_file(
&project_dir.join("resource_pack_ijklmnop/textures/icon.png"),
"icon",
);
let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.2.3").unwrap();
let project_name = project_dir.file_name().unwrap().to_string_lossy();
let expected_path = project_dir.join(format!("{project_name}_release_1.2.3.zip"));
assert_eq!(zip_path, expected_path);
assert!(zip_path.is_file());
fs::remove_dir_all(project_dir).unwrap();
}
#[test]
fn package_writes_to_artifacts_subdir_when_env_set() {
let project_dir = temp_project_dir();
let artifacts_dir = temp_project_dir();
let release_info = release_info();
write_file(
&project_dir.join("behavior_pack_abcdefgh/scripts/main.py"),
"main",
);
write_file(
&project_dir.join("resource_pack_ijklmnop/textures/icon.png"),
"icon",
);
let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path =
package_project_with_artifacts_env(&project_dir, &pack_dirs, "1.0.0", &artifacts_dir)
.unwrap();
let project_name = project_dir.file_name().unwrap().to_string_lossy();
let expected_path = artifacts_dir
.join(project_name.as_ref())
.join(format!("{project_name}_release_1.0.0.zip"));
let project_dir_zip = project_dir.join(format!("{project_name}_release_1.0.0.zip"));
assert_eq!(zip_path, expected_path);
assert!(zip_path.is_file());
assert!(expected_path.parent().unwrap().is_dir());
assert!(!project_dir_zip.exists());
fs::remove_dir_all(project_dir).unwrap();
fs::remove_dir_all(artifacts_dir).unwrap();
}
#[test] #[test]
fn package_project_skips_files_listed_in_emod_ignore() { fn package_project_skips_files_listed_in_emod_ignore() {
let project_dir = temp_project_dir(); let project_dir = temp_project_dir();
@@ -702,7 +757,8 @@ mod tests {
); );
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string()));
@@ -736,7 +792,8 @@ mod tests {
); );
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"behavior_pack_abcdefgh/live/main.py".to_string())); assert!(entries.contains(&"behavior_pack_abcdefgh/live/main.py".to_string()));
@@ -763,7 +820,8 @@ mod tests {
write_file(&project_dir.join("world_resource_packs.json"), "[]"); write_file(&project_dir.join("world_resource_packs.json"), "[]");
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"world_behavior_packs.json".to_string())); assert!(entries.contains(&"world_behavior_packs.json".to_string()));
@@ -796,7 +854,8 @@ mod tests {
write_file(&project_dir.join("misc/notes.md"), "notes"); write_file(&project_dir.join("misc/notes.md"), "notes");
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string()));
@@ -831,7 +890,8 @@ mod tests {
); );
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string()));
@@ -846,7 +906,10 @@ mod tests {
let release_info = release_info(); let release_info = release_info();
write_file(&project_dir.join(".emod-package"), "**/*.json\n"); write_file(&project_dir.join(".emod-package"), "**/*.json\n");
write_file(&project_dir.join("behavior_pack_abcdefgh/manifest.json"), "{}"); write_file(
&project_dir.join("behavior_pack_abcdefgh/manifest.json"),
"{}",
);
write_file( write_file(
&project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"),
"main", "main",
@@ -854,7 +917,8 @@ mod tests {
write_file(&project_dir.join("world_behavior_packs.json"), "[]"); write_file(&project_dir.join("world_behavior_packs.json"), "[]");
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
assert!(entries.contains(&"behavior_pack_abcdefgh/manifest.json".to_string())); assert!(entries.contains(&"behavior_pack_abcdefgh/manifest.json".to_string()));
@@ -885,7 +949,8 @@ mod tests {
write_file(&project_dir.join("world_resource_packs.json"), "[]"); write_file(&project_dir.join("world_resource_packs.json"), "[]");
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
for entry in &entries { for entry in &entries {
@@ -904,7 +969,10 @@ mod tests {
let project_dir = temp_project_dir(); let project_dir = temp_project_dir();
let release_info = release_info(); let release_info = release_info();
write_file(&project_dir.join(".emod-package"), "behavior_pack_abcdefgh/\n"); write_file(
&project_dir.join(".emod-package"),
"behavior_pack_abcdefgh/\n",
);
write_file( write_file(
&project_dir.join("behavior_pack_abcdefgh/BoxData/data.json"), &project_dir.join("behavior_pack_abcdefgh/BoxData/data.json"),
"{}", "{}",
@@ -915,7 +983,8 @@ mod tests {
); );
let pack_dirs = pack_dirs(&project_dir, &release_info); let pack_dirs = pack_dirs(&project_dir, &release_info);
let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); let zip_path =
package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap();
let entries = zip_entries(&zip_path); let entries = zip_entries(&zip_path);
for entry in &entries { for entry in &entries {
@@ -964,7 +1033,48 @@ mod tests {
fs::write(path, content).unwrap(); fs::write(path, content).unwrap();
} }
fn zip_entries(path: &str) -> Vec<String> { fn package_project_with_artifacts_env_unset(
project_dir: &PathBuf,
pack_dirs: &PackDirs,
version: &str,
) -> Result<PathBuf> {
let _guard = ARTIFACTS_ENV_LOCK.lock().unwrap();
remove_artifacts_dir_env();
let result = package_project(project_dir, pack_dirs, version);
remove_artifacts_dir_env();
result
}
fn package_project_with_artifacts_env(
project_dir: &PathBuf,
pack_dirs: &PackDirs,
version: &str,
artifacts_dir: &Path,
) -> Result<PathBuf> {
let _guard = ARTIFACTS_ENV_LOCK.lock().unwrap();
set_artifacts_dir_env(artifacts_dir);
let result = package_project(project_dir, pack_dirs, version);
remove_artifacts_dir_env();
result
}
fn set_artifacts_dir_env(path: &Path) {
// SAFETY: tests serialize every EMOD_ARTIFACTS_DIR mutation and every
// package_project call through ARTIFACTS_ENV_LOCK.
unsafe {
env::set_var(ARTIFACTS_DIR_ENV, path.as_os_str());
}
}
fn remove_artifacts_dir_env() {
// SAFETY: tests serialize every EMOD_ARTIFACTS_DIR mutation and every
// package_project call through ARTIFACTS_ENV_LOCK.
unsafe {
env::remove_var(ARTIFACTS_DIR_ENV);
}
}
fn zip_entries(path: &Path) -> Vec<String> {
let file = File::open(path).unwrap(); let file = File::open(path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap(); let mut archive = zip::ZipArchive::new(file).unwrap();
(0..archive.len()) (0..archive.len())