Skip to main content

qtcloud_devops_cli/release/
publish.rs

1use std::path::Path;
2
3use super::util::{self, PublishTarget};
4use crate::contract;
5
6/// 发布版本。
7///
8/// 内部处理流程:
9/// 1. 校验版本号格式
10/// 2. 从 contract.yaml 获取 scope 子目录
11/// 3. 自动更新 Cargo.toml / pyproject.toml 版本号
12/// 4. 自动生成 CHANGELOG(如有需要)并提交
13/// 5. 校验 CHANGELOG 包含对应版本记录
14/// 6. 用户确认(除非 `yes = true`)
15/// 7. 创建 git tag → 推送 → 创建 GitHub Release
16pub 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    // 从 version 提取 scope 前缀,从契约获取子目录
29    let scope_dir = resolve_scope_dir(version, repo_path);
30
31    // 自动更新配置文件版本(scope 子目录下)—— 先于一致性检查
32    update_config_version(&scope_dir, &ver);
33
34    // 预检:所有配置文件版本号一致
35    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    // git add 配置文件,让 ensure_changelog 的 commit 一并提交
52    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    // 自动生成 CHANGELOG(scope 子目录下,git 操作在 repo 根)
66    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
108/// 从 version 字符串提取 scope,查契约得到子目录。
109fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
110    // "cli/v0.6.0" → scope="cli", "v0.1.0" → scope="(root)"
111    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    // 从契约查找 scope
120    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    // 回退:scope 名作为子目录
128    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
136/// 更新 Cargo.toml / pyproject.toml 中的版本号。
137fn 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        // git add + commit 所有文件
322        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}