qtcloud_devops_cli/release/
publish.rs1use std::path::Path;
2
3use super::util::{self, PublishTarget};
4use crate::contract;
5
6pub fn publish(
19 version: Option<&str>,
20 repo_path: &Path,
21 yes: bool,
22 force: bool,
23 dry_run: bool,
24 registry: Option<PublishTarget>,
25) -> Result<(), Box<dyn std::error::Error>> {
26 let version = match version {
28 Some(v) => {
29 if !util::validate_version(v) {
30 return Err(format!("版本号格式错误: {}", v).into());
31 }
32 v.to_string()
33 }
34 None => {
35 let result = super::detect::detect_version(repo_path)?;
36 if dry_run {
37 println!("\n💡 建议版本: {}", result.version);
38 println!(" 使用 -v 指定版本执行发布,或直接运行不加 --dry-run");
39 return Ok(());
40 }
41 result.version
42 }
43 };
44
45 if dry_run {
47 println!("\n💡 预览发布: {}", version);
48 println!(" 将更新 Cargo.toml/pyproject.toml 版本号");
49 println!(" 将更新 CHANGELOG.md");
50 println!(" 将创建 git tag 并推送到远端");
51 println!(" 将创建 GitHub Release");
52 println!(" 使用 -y 跳过确认直接发布");
53 return Ok(());
54 }
55
56 let ver = util::normalize_version(&version);
57
58 let scope_dir = resolve_scope_dir(&version, repo_path);
60
61 update_config_version(&scope_dir, &ver);
63
64 if force {
66 if let Some(repo) = super::util::get_remote_repo(repo_path) {
67 eprintln!("🔁 强制重新发布,清理旧资源...");
68 super::util::delete_release(&version, &repo);
69 }
70 super::util::delete_remote_tag(&version, repo_path);
71 super::util::delete_local_tag(&version, repo_path);
72 }
73
74 let config_files = contract::read_all_config_versions(&scope_dir);
76 let inconsistent: Vec<&(String, Option<String>)> = config_files
77 .iter()
78 .filter(|(_, v)| match v {
79 Some(cv) => cv != &ver,
80 None => false,
81 })
82 .collect();
83 if !inconsistent.is_empty() {
84 for (fname, v) in &inconsistent {
85 let v_display = v.as_deref().unwrap_or("?");
86 eprintln!("⚠ {}: 版本 {} 与目标 {} 不一致", fname, v_display, ver);
87 }
88 return Err("存在版本号不一致的配置文件,请先同步".into());
89 }
90
91 if scope_dir.join("Cargo.toml").exists() {
93 let lockfile_updated = std::process::Command::new("cargo")
94 .args(["generate-lockfile"])
95 .current_dir(&scope_dir)
96 .output()
97 .map(|o| o.status.success())
98 .unwrap_or(false);
99 if lockfile_updated {
100 println!("✓ Cargo.lock 已同步");
101 }
102 }
103
104 for f in &["Cargo.toml", "pyproject.toml", "Cargo.lock"] {
106 let path = scope_dir.join(f);
107 if path.exists() {
108 if let Ok(rel) = path.strip_prefix(repo_path) {
109 std::process::Command::new("git")
110 .args(["add", rel.to_str().unwrap_or(f)])
111 .current_dir(repo_path)
112 .output()
113 .ok();
114 }
115 }
116 }
117
118 if let Err(e) = super::ensure_changelog(repo_path, &scope_dir, &version) {
120 eprintln!(
121 "⚠ CHANGELOG 生成失败: {}\n 发布将继续,但请确保 CHANGELOG.md 包含版本 {} 的记录。",
122 e, version
123 );
124 }
125
126 let changelog_path = scope_dir.join("CHANGELOG.md");
127 let precheck_errors = util::precheck_version_changelog(&version, &changelog_path);
128 if !precheck_errors.is_empty() {
129 return Err(precheck_errors.join("\n").into());
130 }
131
132 if !yes && !util::confirm_release(&version, false) {
133 return Err("已取消发布".into());
134 }
135
136 if !util::create_tag(&version, repo_path) {
137 return Err(format!("创建标签 {} 失败", version).into());
138 }
139 if !util::push_tag(&version, repo_path) {
140 util::rollback_tag(&version, repo_path);
141 return Err(format!("推送标签 {} 失败", version).into());
142 }
143 println!("✓ 标签 {} 已创建并推送", version);
144
145 let notes = util::extract_notes(&version, &changelog_path);
146 if let Some(repo) = util::get_remote_repo(repo_path) {
147 if !util::create_release(&version, notes.as_deref().unwrap_or(""), &repo) {
148 util::rollback_tag(&version, repo_path);
149 return Err("创建 GitHub Release 失败".into());
150 }
151 println!("✓ GitHub Release {} 已创建", version);
152 println!(" https://github.com/{}/releases/tag/{}", repo, version);
153 }
154 if let Some(reg) = registry {
155 println!(" {:?} 由 CI 自动发布,无需本地操作", reg);
156 }
157 println!("✓ 版本 {} 已发布", version);
158 Ok(())
159}
160
161fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
163 let scope_name = if version.contains('/') {
165 version.split('/').next().unwrap_or("")
166 } else {
167 "(root)"
168 };
169 if scope_name == "(root)" || scope_name.is_empty() {
170 return repo_path.to_path_buf();
171 }
172 let scopes = contract::load_scopes(repo_path);
174 if let Some(s) = scopes.iter().find(|s| s.name == scope_name) {
175 let d = repo_path.join(&s.dir);
176 if d.exists() {
177 return d;
178 }
179 }
180 let d = repo_path.join(scope_name);
182 if d.is_dir() {
183 d
184 } else {
185 repo_path.to_path_buf()
186 }
187}
188
189fn update_config_version(repo_path: &Path, version: &str) {
191 for filename in &["Cargo.toml", "pyproject.toml"] {
192 let path = repo_path.join(filename);
193 let content = match std::fs::read_to_string(&path) {
194 Ok(c) => c,
195 Err(_) => continue,
196 };
197 let updated = update_version_in_content(&content, version);
198 if updated != content {
199 std::fs::write(&path, &updated).ok();
200 println!("✓ {} 版本已更新为 {}", filename, version);
201 }
202 }
203}
204
205fn update_version_in_content(content: &str, new_ver: &str) -> String {
206 let mut result = String::new();
207 for line in content.lines() {
208 let trimmed = line.trim();
209 if trimmed.starts_with("version = \"") {
210 let indent = &line[..line.find("version = \"").unwrap()];
211 result.push_str(&format!("{}version = \"{}\"\n", indent, new_ver));
212 } else if trimmed.starts_with("\"version\":") {
213 let indent = &line[..line.find("\"version\":").unwrap()];
214 result.push_str(&format!("{}\"version\": \"{}\",\n", indent, new_ver));
215 } else {
216 result.push_str(line);
217 result.push('\n');
218 }
219 }
220 result
221}
222
223#[cfg(test)]
224mod tests {
225 fn git_init(path: &std::path::Path) {
226 let repo = git2::Repository::init(path).unwrap();
227 let mut cfg = repo.config().unwrap();
228 cfg.set_str("user.email", "t@t").unwrap();
229 cfg.set_str("user.name", "t").unwrap();
230 }
231
232 fn git_commit(path: &std::path::Path, msg: &str) {
233 std::fs::write(path.join("f"), msg).unwrap();
234 let repo = git2::Repository::open(path).unwrap();
235 let mut index = repo.index().unwrap();
236 index.add_path(std::path::Path::new("f")).unwrap();
237 index.write().unwrap();
238 let tree_id = index.write_tree().unwrap();
239 let tree = repo.find_tree(tree_id).unwrap();
240 let sig = repo.signature().unwrap();
241 let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
242 let parents: Vec<&git2::Commit> = parent.iter().collect();
243 repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
244 .unwrap();
245 }
246
247 use super::*;
248
249 #[test]
250 fn test_publish_rejects_invalid_version() {
251 assert!(publish(
252 Some("bad"),
253 tempfile::tempdir().unwrap().path(),
254 true,
255 false,
256 false,
257 None
258 )
259 .is_err());
260 }
261 #[test]
262 fn test_publish_auto_generates_changelog() {
263 let d = tempfile::tempdir().unwrap();
264 git_init(d.path());
265 git_commit(d.path(), "init");
266 let result = publish(Some("v1.0.0"), d.path(), true, false, false, None);
267 assert!(result.is_ok());
268 let changelog = std::fs::read_to_string(d.path().join("CHANGELOG.md")).unwrap_or_default();
269 assert!(changelog.contains("## [1.0.0]"));
270 }
271 #[test]
272 fn test_publish_formal_with_yes() {
273 let d = tempfile::tempdir().unwrap();
274 let r = publish(Some("v1.0.0"), d.path(), true, false, false, None);
275 assert!(r.is_ok() || r.is_err());
276 }
277 #[test]
278 fn test_publish_prerelease_with_yes() {
279 let d = tempfile::tempdir().unwrap();
280 git_init(d.path());
281 git_commit(d.path(), "init");
282 std::fs::write(
283 d.path().join("CHANGELOG.md"),
284 "## [1.0.0-rc.1]\n\ncontent\n",
285 )
286 .unwrap();
287 let r = publish(Some("v1.0.0-rc.1"), d.path(), true, false, false, None);
288 assert!(r.is_ok() || r.is_err());
289 }
290 #[test]
291 fn test_update_version_in_content_toml() {
292 let content = "name = \"foo\"\nversion = \"0.1.0\"\n";
293 assert_eq!(
294 update_version_in_content(content, "0.2.0"),
295 "name = \"foo\"\nversion = \"0.2.0\"\n"
296 );
297 }
298 #[test]
299 fn test_update_version_in_content_json() {
300 let content = "{\n \"version\": \"1.0.0\",\n}\n";
301 let result = update_version_in_content(content, "2.0.0");
302 assert!(result.contains("\"version\": \"2.0.0\""));
303 }
304
305 #[test]
306 fn test_resolve_scope_dir_with_contract() {
307 let d = tempfile::tempdir().unwrap();
308 let contract_dir = d.path().join(".quanttide/devops");
309 std::fs::create_dir_all(&contract_dir).unwrap();
310 std::fs::write(
311 contract_dir.join("contract.yaml"),
312 "scopes:\n cli:\n dir: packages/cli\n language: rust\n",
313 )
314 .unwrap();
315 std::fs::create_dir_all(d.path().join("packages/cli")).unwrap();
316 let resolved = resolve_scope_dir("cli/v0.1.0", d.path());
317 assert!(
318 resolved.ends_with("packages/cli"),
319 "预期以 packages/cli 结尾,但得到: {:?}",
320 resolved
321 );
322 }
323
324 #[test]
325 fn test_update_config_version_creates_files() {
326 let d = tempfile::tempdir().unwrap();
327 std::fs::write(
328 d.path().join("Cargo.toml"),
329 "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
330 )
331 .unwrap();
332 std::fs::write(
333 d.path().join("pyproject.toml"),
334 "[project]\nname = \"test\"\nversion = \"0.1.0\"\n",
335 )
336 .unwrap();
337 update_config_version(d.path(), "0.2.0");
338 let cargo = std::fs::read_to_string(d.path().join("Cargo.toml")).unwrap();
339 assert!(cargo.contains("version = \"0.2.0\""));
340 let pyproject = std::fs::read_to_string(d.path().join("pyproject.toml")).unwrap();
341 assert!(pyproject.contains("version = \"0.2.0\""));
342 }
343
344 #[test]
345 fn test_publish_scoped_version_with_contract() {
346 let d = tempfile::tempdir().unwrap();
347 git_init(d.path());
348 git_commit(d.path(), "init");
349
350 let contract_dir = d.path().join(".quanttide/devops");
351 std::fs::create_dir_all(&contract_dir).unwrap();
352 std::fs::write(
353 contract_dir.join("contract.yaml"),
354 "scopes:\n cli:\n dir: packages/cli\n language: rust\n",
355 )
356 .unwrap();
357
358 let scope_dir = d.path().join("packages/cli");
359 std::fs::create_dir_all(&scope_dir).unwrap();
360 std::fs::write(
361 scope_dir.join("Cargo.toml"),
362 "[package]\nname = \"cli\"\nversion = \"0.1.0\"\n",
363 )
364 .unwrap();
365 std::fs::write(
366 scope_dir.join("CHANGELOG.md"),
367 "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n",
368 )
369 .unwrap();
370
371 std::process::Command::new("git")
373 .args(["add", "."])
374 .current_dir(d.path())
375 .output()
376 .unwrap();
377 std::process::Command::new("git")
378 .args(["commit", "-m", "setup scope"])
379 .current_dir(d.path())
380 .output()
381 .unwrap();
382
383 let result = publish(Some("cli/v0.2.0"), d.path(), true, false, false, None);
384 assert!(result.is_ok(), "publish 失败: {:?}", result.err());
385
386 let cargo = std::fs::read_to_string(scope_dir.join("Cargo.toml")).unwrap();
387 assert!(cargo.contains("version = \"0.2.0\""));
388 }
389}