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