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 registry: Option<PublishTarget>,
21) -> Result<(), Box<dyn std::error::Error>> {
22 if !util::validate_version(version) {
23 return Err(format!("版本号格式错误: {}", version).into());
24 }
25
26 let ver = util::normalize_version(version);
27
28 let scope_dir = resolve_scope_dir(version, repo_path);
30
31 let config_files = contract::read_all_config_versions(&scope_dir);
33 let inconsistent: Vec<&(String, Option<String>)> = config_files
34 .iter()
35 .filter(|(_, v)| match v {
36 Some(cv) => cv != &ver,
37 None => false,
38 })
39 .collect();
40 if !inconsistent.is_empty() {
41 for (fname, v) in &inconsistent {
42 let v_display = v.as_deref().unwrap_or("?");
43 eprintln!("⚠ {}: 版本 {} 与目标 {} 不一致", fname, v_display, ver);
44 }
45 return Err("存在版本号不一致的配置文件,请先同步".into());
46 }
47
48 update_config_version(&scope_dir, &ver);
50 for f in &["Cargo.toml", "pyproject.toml"] {
52 let path = scope_dir.join(f);
53 if path.exists() {
54 if let Ok(rel) = path.strip_prefix(repo_path) {
55 std::process::Command::new("git")
56 .args(["add", rel.to_str().unwrap_or(f)])
57 .current_dir(repo_path)
58 .output()
59 .ok();
60 }
61 }
62 }
63
64 if let Err(e) = super::ensure_changelog(&scope_dir, version) {
66 eprintln!(
67 "⚠ CHANGELOG 生成失败: {}\n 发布将继续,但请确保 CHANGELOG.md 包含版本 {} 的记录。",
68 e, version
69 );
70 }
71
72 let changelog_path = scope_dir.join("CHANGELOG.md");
73 let precheck_errors = util::precheck_version_changelog(version, &changelog_path);
74 if !precheck_errors.is_empty() {
75 return Err(precheck_errors.join("\n").into());
76 }
77
78 if !yes && !util::confirm_release(version, false) {
79 return Err("已取消发布".into());
80 }
81
82 if !util::create_tag(version, repo_path) {
83 return Err(format!("创建标签 {} 失败", version).into());
84 }
85 if !util::push_tag(version, repo_path) {
86 util::rollback_tag(version, repo_path);
87 return Err(format!("推送标签 {} 失败", version).into());
88 }
89 println!("✓ 标签 {} 已创建并推送", version);
90
91 let notes = util::extract_notes(version, &changelog_path);
92 if let Some(repo) = util::get_remote_repo(repo_path) {
93 if !util::create_release(version, notes.as_deref().unwrap_or(""), &repo) {
94 util::rollback_tag(version, repo_path);
95 return Err("创建 GitHub Release 失败".into());
96 }
97 println!("✓ GitHub Release {} 已创建", version);
98 println!(" https://github.com/{}/releases/tag/{}", repo, version);
99 }
100 if let Some(reg) = registry {
101 println!(" {:?} 由 CI 自动发布,无需本地操作", reg);
102 }
103 println!("✓ 版本 {} 已发布", version);
104 Ok(())
105}
106
107fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
109 let scope_name = if version.contains('/') {
111 version.split('/').next().unwrap_or("")
112 } else {
113 "(root)"
114 };
115 if scope_name == "(root)" || scope_name.is_empty() {
116 return repo_path.to_path_buf();
117 }
118 let scopes = contract::load_scopes(repo_path);
120 if let Some(s) = scopes.iter().find(|s| s.name == scope_name) {
121 let d = repo_path.join(&s.dir);
122 if d.exists() {
123 return d;
124 }
125 }
126 let d = repo_path.join(scope_name);
128 if d.is_dir() {
129 d
130 } else {
131 repo_path.to_path_buf()
132 }
133}
134
135fn update_config_version(repo_path: &Path, version: &str) {
137 for filename in &["Cargo.toml", "pyproject.toml"] {
138 let path = repo_path.join(filename);
139 let content = match std::fs::read_to_string(&path) {
140 Ok(c) => c,
141 Err(_) => continue,
142 };
143 let updated = update_version_in_content(&content, version);
144 if updated != content {
145 std::fs::write(&path, &updated).ok();
146 println!("✓ {} 版本已更新为 {}", filename, version);
147 }
148 }
149}
150
151fn update_version_in_content(content: &str, new_ver: &str) -> String {
152 let mut result = String::new();
153 for line in content.lines() {
154 let trimmed = line.trim();
155 if trimmed.starts_with("version = \"") {
156 let indent = &line[..line.find("version = \"").unwrap()];
157 result.push_str(&format!("{}version = \"{}\"\n", indent, new_ver));
158 } else if trimmed.starts_with("\"version\":") {
159 let indent = &line[..line.find("\"version\":").unwrap()];
160 result.push_str(&format!("{}\"version\": \"{}\",\n", indent, new_ver));
161 } else {
162 result.push_str(line);
163 result.push('\n');
164 }
165 }
166 result
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::path::Path;
173
174 fn git_init(path: &Path) {
175 std::process::Command::new("git")
176 .args(["init", "-b", "main"])
177 .current_dir(path)
178 .output()
179 .unwrap();
180 std::process::Command::new("git")
181 .args(["config", "user.email", "test@test.com"])
182 .current_dir(path)
183 .output()
184 .unwrap();
185 std::process::Command::new("git")
186 .args(["config", "user.name", "Test"])
187 .current_dir(path)
188 .output()
189 .unwrap();
190 }
191
192 fn git_commit(path: &Path, msg: &str) {
193 std::fs::write(path.join("file"), msg).unwrap();
194 std::process::Command::new("git")
195 .args(["add", "."])
196 .current_dir(path)
197 .output()
198 .unwrap();
199 std::process::Command::new("git")
200 .args(["commit", "-m", msg])
201 .current_dir(path)
202 .output()
203 .unwrap();
204 }
205
206 #[test]
207 fn test_publish_rejects_invalid_version() {
208 assert!(publish("bad", tempfile::tempdir().unwrap().path(), true, None).is_err());
209 }
210 #[test]
211 fn test_publish_auto_generates_changelog() {
212 let d = tempfile::tempdir().unwrap();
213 git_init(d.path());
214 git_commit(d.path(), "init");
215 let result = publish("v1.0.0", d.path(), true, None);
216 assert!(result.is_ok());
217 let changelog = std::fs::read_to_string(d.path().join("CHANGELOG.md")).unwrap_or_default();
218 assert!(changelog.contains("## [1.0.0]"));
219 }
220 #[test]
221 fn test_publish_formal_with_yes() {
222 let d = tempfile::tempdir().unwrap();
223 let r = publish("v1.0.0", d.path(), true, None);
224 assert!(r.is_ok() || r.is_err());
225 }
226 #[test]
227 fn test_publish_prerelease_with_yes() {
228 let d = tempfile::tempdir().unwrap();
229 git_init(d.path());
230 git_commit(d.path(), "init");
231 std::fs::write(
232 d.path().join("CHANGELOG.md"),
233 "## [1.0.0-rc.1]\n\ncontent\n",
234 )
235 .unwrap();
236 let r = publish("v1.0.0-rc.1", d.path(), true, None);
237 assert!(r.is_ok() || r.is_err());
238 }
239 #[test]
240 fn test_update_version_in_content_toml() {
241 let content = "name = \"foo\"\nversion = \"0.1.0\"\n";
242 assert_eq!(
243 update_version_in_content(content, "0.2.0"),
244 "name = \"foo\"\nversion = \"0.2.0\"\n"
245 );
246 }
247 #[test]
248 fn test_update_version_in_content_json() {
249 let content = "{\n \"version\": \"1.0.0\",\n}\n";
250 let result = update_version_in_content(content, "2.0.0");
251 assert!(result.contains("\"version\": \"2.0.0\""));
252 }
253}