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    // 预检:所有配置文件版本号一致
32    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    // 自动更新配置文件版本(scope 子目录下)
49    update_config_version(&scope_dir, &ver);
50    // git add 配置文件,让 ensure_changelog 的 commit 一并提交
51    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    // 自动生成 CHANGELOG(scope 子目录下)
65    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
107/// 从 version 字符串提取 scope,查契约得到子目录。
108fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
109    // "cli/v0.6.0" → scope="cli", "v0.1.0" → scope="(root)"
110    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    // 从契约查找 scope
119    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    // 回退:scope 名作为子目录
127    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
135/// 更新 Cargo.toml / pyproject.toml 中的版本号。
136fn 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}