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