Skip to main content

qtcloud_devops_cli/release/
publish.rs

1use std::path::Path;
2
3use super::util::{self, Registry};
4
5/// 发布版本。
6///
7/// 内部处理流程:
8/// 1. 校验版本号格式(需匹配 `vX.Y.Z` 或 `scope/vX.Y.Z`)
9/// 2. 校验 CHANGELOG.md 存在且包含对应版本记录
10/// 3. 用户确认(除非 `yes = true`)
11/// 4. 创建 git tag(幂等,已存在时跳过)
12/// 5. 推送 tag 到远端(无远端时静默跳过)
13/// 6. 创建 GitHub Release(幂等,已存在时跳过)
14/// 7. 打印 registry 发布提示(不实际发布,由 CI 执行)
15///
16/// 回滚:步骤 5 失败时删除本地 tag;步骤 6 失败时删除本地和远端 tag。
17///
18/// # 参数
19/// - `version`: 版本号。格式 `vX.Y.Z` 或 `scope/vX.Y.Z`(如 `cli/v0.5.0`)
20/// - `repo_path`: git 仓库路径
21/// - `yes`: 跳过用户确认
22/// - `registry`: CI 发布目标提示(仅打印,不执行)
23pub fn publish(
24    version: &str,
25    repo_path: &Path,
26    yes: bool,
27    registry: Option<Registry>,
28) -> Result<(), Box<dyn std::error::Error>> {
29    if !util::validate_version(version) {
30        return Err(format!("版本号格式错误: {}", version).into());
31    }
32
33    // 自动生成 CHANGELOG(如果不存在当前版本记录)
34    if let Err(e) = super::ensure_changelog(repo_path, version) {
35        eprintln!(
36            "⚠ CHANGELOG 生成失败: {}\n   发布将继续,但请确保 CHANGELOG.md 包含版本 {} 的记录。",
37            e, version
38        );
39        // 不阻塞发布,仅输出警告
40    }
41
42    let changelog_path = repo_path.join("CHANGELOG.md");
43    let precheck_errors = util::precheck_version_changelog(version, &changelog_path);
44    if !precheck_errors.is_empty() {
45        return Err(precheck_errors.join("\n").into());
46    }
47
48    if !yes && !util::confirm_release(version, false) {
49        return Err("已取消发布".into());
50    }
51
52    if !util::create_tag(version, repo_path) {
53        return Err(format!("创建标签 {} 失败", version).into());
54    }
55    if !util::push_tag(version, repo_path) {
56        util::rollback_tag(version, repo_path);
57        return Err(format!("推送标签 {} 失败", version).into());
58    }
59    println!("✓ 标签 {} 已创建并推送", version);
60
61    let notes = util::extract_notes(version, &changelog_path);
62    if let Some(repo) = util::get_remote_repo(repo_path) {
63        if !util::create_release(version, notes.as_deref().unwrap_or(""), &repo) {
64            util::rollback_tag(version, repo_path);
65            return Err("创建 GitHub Release 失败".into());
66        }
67        println!("✓ GitHub Release {} 已创建", version);
68        println!("  https://github.com/{}/releases/tag/{}", repo, version);
69    }
70    if let Some(reg) = registry {
71        println!("  {:?} 由 CI 自动发布,无需本地操作", reg);
72    }
73    println!("✓ 版本 {} 已发布", version);
74    Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::path::Path;
81
82    fn git_init(path: &Path) {
83        std::process::Command::new("git")
84            .args(["init", "-b", "main"])
85            .current_dir(path)
86            .output()
87            .unwrap();
88        std::process::Command::new("git")
89            .args(["config", "user.email", "test@test.com"])
90            .current_dir(path)
91            .output()
92            .unwrap();
93        std::process::Command::new("git")
94            .args(["config", "user.name", "Test"])
95            .current_dir(path)
96            .output()
97            .unwrap();
98    }
99
100    fn git_commit(path: &Path, msg: &str) {
101        std::fs::write(path.join("file"), msg).unwrap();
102        std::process::Command::new("git")
103            .args(["add", "."])
104            .current_dir(path)
105            .output()
106            .unwrap();
107        std::process::Command::new("git")
108            .args(["commit", "-m", msg])
109            .current_dir(path)
110            .output()
111            .unwrap();
112    }
113
114    #[test]
115    fn test_publish_rejects_invalid_version() {
116        assert!(publish("bad", tempfile::tempdir().unwrap().path(), true, None).is_err());
117    }
118    #[test]
119    fn test_publish_rejects_missing_changelog() {
120        let d = tempfile::tempdir().unwrap();
121        git_init(d.path());
122        git_commit(d.path(), "init");
123        let e = publish("v1.0.0", d.path(), true, None)
124            .unwrap_err()
125            .to_string();
126        assert!(e.contains("CHANGELOG"));
127    }
128    #[test]
129    fn test_publish_formal_with_yes() {
130        let d = tempfile::tempdir().unwrap();
131        let r = publish("v1.0.0", d.path(), true, None);
132        assert!(r.is_ok() || r.is_err());
133    }
134    #[test]
135    fn test_publish_prerelease_with_yes() {
136        let d = tempfile::tempdir().unwrap();
137        git_init(d.path());
138        git_commit(d.path(), "init");
139        std::fs::write(
140            d.path().join("CHANGELOG.md"),
141            "## [1.0.0-rc.1]\n\ncontent\n",
142        )
143        .unwrap();
144        let r = publish("v1.0.0-rc.1", d.path(), true, None);
145        assert!(r.is_ok() || r.is_err());
146    }
147}