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    // 如果是 Rust 项目,更新 Cargo.lock 保持与 Cargo.toml 同步
52    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    // git add 配置文件,让 ensure_changelog 的 commit 一并提交
65    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    // 自动生成 CHANGELOG(scope 子目录下,git 操作在 repo 根)
79    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
121/// 从 version 字符串提取 scope,查契约得到子目录。
122fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
123    // "cli/v0.6.0" → scope="cli", "v0.1.0" → scope="(root)"
124    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    // 从契约查找 scope
133    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    // 回退:scope 名作为子目录
141    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
149/// 更新 Cargo.toml / pyproject.toml 中的版本号。
150fn 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        // git add + commit 所有文件
335        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}