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