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