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    force: bool,
21    registry: Option<PublishTarget>,
22) -> Result<(), Box<dyn std::error::Error>> {
23    if !util::validate_version(version) {
24        return Err(format!("版本号格式错误: {}", version).into());
25    }
26
27    let ver = util::normalize_version(version);
28
29    // 从 version 提取 scope 前缀,从契约获取子目录
30    let scope_dir = resolve_scope_dir(version, repo_path);
31
32    // 自动更新配置文件版本(scope 子目录下)—— 先于一致性检查
33    update_config_version(&scope_dir, &ver);
34
35    // 强制模式:清理已存在的 tag 和 Release,允许重新发布
36    if force {
37        if let Some(repo) = super::util::get_remote_repo(repo_path) {
38            eprintln!("🔁 强制重新发布,清理旧资源...");
39            super::util::delete_release(version, &repo);
40        }
41        super::util::delete_remote_tag(version, repo_path);
42        super::util::delete_local_tag(version, repo_path);
43    }
44
45    // 预检:所有配置文件版本号一致
46    let config_files = contract::read_all_config_versions(&scope_dir);
47    let inconsistent: Vec<&(String, Option<String>)> = config_files
48        .iter()
49        .filter(|(_, v)| match v {
50            Some(cv) => cv != &ver,
51            None => false,
52        })
53        .collect();
54    if !inconsistent.is_empty() {
55        for (fname, v) in &inconsistent {
56            let v_display = v.as_deref().unwrap_or("?");
57            eprintln!("⚠ {}: 版本 {} 与目标 {} 不一致", fname, v_display, ver);
58        }
59        return Err("存在版本号不一致的配置文件,请先同步".into());
60    }
61
62    // 如果是 Rust 项目,更新 Cargo.lock 保持与 Cargo.toml 同步
63    if scope_dir.join("Cargo.toml").exists() {
64        let lockfile_updated = std::process::Command::new("cargo")
65            .args(["generate-lockfile"])
66            .current_dir(&scope_dir)
67            .output()
68            .map(|o| o.status.success())
69            .unwrap_or(false);
70        if lockfile_updated {
71            println!("✓ Cargo.lock 已同步");
72        }
73    }
74
75    // git add 配置文件,让 ensure_changelog 的 commit 一并提交
76    for f in &["Cargo.toml", "pyproject.toml", "Cargo.lock"] {
77        let path = scope_dir.join(f);
78        if path.exists() {
79            if let Ok(rel) = path.strip_prefix(repo_path) {
80                std::process::Command::new("git")
81                    .args(["add", rel.to_str().unwrap_or(f)])
82                    .current_dir(repo_path)
83                    .output()
84                    .ok();
85            }
86        }
87    }
88
89    // 自动生成 CHANGELOG(scope 子目录下,git 操作在 repo 根)
90    if let Err(e) = super::ensure_changelog(repo_path, &scope_dir, version) {
91        eprintln!(
92            "⚠ CHANGELOG 生成失败: {}\n   发布将继续,但请确保 CHANGELOG.md 包含版本 {} 的记录。",
93            e, version
94        );
95    }
96
97    let changelog_path = scope_dir.join("CHANGELOG.md");
98    let precheck_errors = util::precheck_version_changelog(version, &changelog_path);
99    if !precheck_errors.is_empty() {
100        return Err(precheck_errors.join("\n").into());
101    }
102
103    if !yes && !util::confirm_release(version, false) {
104        return Err("已取消发布".into());
105    }
106
107    if !util::create_tag(version, repo_path) {
108        return Err(format!("创建标签 {} 失败", version).into());
109    }
110    if !util::push_tag(version, repo_path) {
111        util::rollback_tag(version, repo_path);
112        return Err(format!("推送标签 {} 失败", version).into());
113    }
114    println!("✓ 标签 {} 已创建并推送", version);
115
116    let notes = util::extract_notes(version, &changelog_path);
117    if let Some(repo) = util::get_remote_repo(repo_path) {
118        if !util::create_release(version, notes.as_deref().unwrap_or(""), &repo) {
119            util::rollback_tag(version, repo_path);
120            return Err("创建 GitHub Release 失败".into());
121        }
122        println!("✓ GitHub Release {} 已创建", version);
123        println!("  https://github.com/{}/releases/tag/{}", repo, version);
124    }
125    if let Some(reg) = registry {
126        println!("  {:?} 由 CI 自动发布,无需本地操作", reg);
127    }
128    println!("✓ 版本 {} 已发布", version);
129    Ok(())
130}
131
132/// 从 version 字符串提取 scope,查契约得到子目录。
133fn resolve_scope_dir(version: &str, repo_path: &Path) -> std::path::PathBuf {
134    // "cli/v0.6.0" → scope="cli", "v0.1.0" → scope="(root)"
135    let scope_name = if version.contains('/') {
136        version.split('/').next().unwrap_or("")
137    } else {
138        "(root)"
139    };
140    if scope_name == "(root)" || scope_name.is_empty() {
141        return repo_path.to_path_buf();
142    }
143    // 从契约查找 scope
144    let scopes = contract::load_scopes(repo_path);
145    if let Some(s) = scopes.iter().find(|s| s.name == scope_name) {
146        let d = repo_path.join(&s.dir);
147        if d.exists() {
148            return d;
149        }
150    }
151    // 回退:scope 名作为子目录
152    let d = repo_path.join(scope_name);
153    if d.is_dir() {
154        d
155    } else {
156        repo_path.to_path_buf()
157    }
158}
159
160/// 更新 Cargo.toml / pyproject.toml 中的版本号。
161fn update_config_version(repo_path: &Path, version: &str) {
162    for filename in &["Cargo.toml", "pyproject.toml"] {
163        let path = repo_path.join(filename);
164        let content = match std::fs::read_to_string(&path) {
165            Ok(c) => c,
166            Err(_) => continue,
167        };
168        let updated = update_version_in_content(&content, version);
169        if updated != content {
170            std::fs::write(&path, &updated).ok();
171            println!("✓ {} 版本已更新为 {}", filename, version);
172        }
173    }
174}
175
176fn update_version_in_content(content: &str, new_ver: &str) -> String {
177    let mut result = String::new();
178    for line in content.lines() {
179        let trimmed = line.trim();
180        if trimmed.starts_with("version = \"") {
181            let indent = &line[..line.find("version = \"").unwrap()];
182            result.push_str(&format!("{}version = \"{}\"\n", indent, new_ver));
183        } else if trimmed.starts_with("\"version\":") {
184            let indent = &line[..line.find("\"version\":").unwrap()];
185            result.push_str(&format!("{}\"version\": \"{}\",\n", indent, new_ver));
186        } else {
187            result.push_str(line);
188            result.push('\n');
189        }
190    }
191    result
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::path::Path;
198
199    fn git_init(path: &Path) {
200        std::process::Command::new("git")
201            .args(["init", "-b", "main"])
202            .current_dir(path)
203            .output()
204            .unwrap();
205        std::process::Command::new("git")
206            .args(["config", "user.email", "test@test.com"])
207            .current_dir(path)
208            .output()
209            .unwrap();
210        std::process::Command::new("git")
211            .args(["config", "user.name", "Test"])
212            .current_dir(path)
213            .output()
214            .unwrap();
215    }
216
217    fn git_commit(path: &Path, msg: &str) {
218        std::fs::write(path.join("file"), msg).unwrap();
219        std::process::Command::new("git")
220            .args(["add", "."])
221            .current_dir(path)
222            .output()
223            .unwrap();
224        std::process::Command::new("git")
225            .args(["commit", "-m", msg])
226            .current_dir(path)
227            .output()
228            .unwrap();
229    }
230
231    #[test]
232    fn test_publish_rejects_invalid_version() {
233        assert!(publish(
234            "bad",
235            tempfile::tempdir().unwrap().path(),
236            true,
237            false,
238            None
239        )
240        .is_err());
241    }
242    #[test]
243    fn test_publish_auto_generates_changelog() {
244        let d = tempfile::tempdir().unwrap();
245        git_init(d.path());
246        git_commit(d.path(), "init");
247        let result = publish("v1.0.0", d.path(), true, false, None);
248        assert!(result.is_ok());
249        let changelog = std::fs::read_to_string(d.path().join("CHANGELOG.md")).unwrap_or_default();
250        assert!(changelog.contains("## [1.0.0]"));
251    }
252    #[test]
253    fn test_publish_formal_with_yes() {
254        let d = tempfile::tempdir().unwrap();
255        let r = publish("v1.0.0", d.path(), true, false, None);
256        assert!(r.is_ok() || r.is_err());
257    }
258    #[test]
259    fn test_publish_prerelease_with_yes() {
260        let d = tempfile::tempdir().unwrap();
261        git_init(d.path());
262        git_commit(d.path(), "init");
263        std::fs::write(
264            d.path().join("CHANGELOG.md"),
265            "## [1.0.0-rc.1]\n\ncontent\n",
266        )
267        .unwrap();
268        let r = publish("v1.0.0-rc.1", d.path(), true, false, None);
269        assert!(r.is_ok() || r.is_err());
270    }
271    #[test]
272    fn test_update_version_in_content_toml() {
273        let content = "name = \"foo\"\nversion = \"0.1.0\"\n";
274        assert_eq!(
275            update_version_in_content(content, "0.2.0"),
276            "name = \"foo\"\nversion = \"0.2.0\"\n"
277        );
278    }
279    #[test]
280    fn test_update_version_in_content_json() {
281        let content = "{\n  \"version\": \"1.0.0\",\n}\n";
282        let result = update_version_in_content(content, "2.0.0");
283        assert!(result.contains("\"version\": \"2.0.0\""));
284    }
285
286    #[test]
287    fn test_resolve_scope_dir_with_contract() {
288        let d = tempfile::tempdir().unwrap();
289        let contract_dir = d.path().join(".quanttide/devops");
290        std::fs::create_dir_all(&contract_dir).unwrap();
291        std::fs::write(
292            contract_dir.join("contract.yaml"),
293            "scopes:\n  cli:\n    dir: packages/cli\n    language: rust\n",
294        )
295        .unwrap();
296        std::fs::create_dir_all(d.path().join("packages/cli")).unwrap();
297        let resolved = resolve_scope_dir("cli/v0.1.0", d.path());
298        assert!(
299            resolved.ends_with("packages/cli"),
300            "预期以 packages/cli 结尾,但得到: {:?}",
301            resolved
302        );
303    }
304
305    #[test]
306    fn test_update_config_version_creates_files() {
307        let d = tempfile::tempdir().unwrap();
308        std::fs::write(
309            d.path().join("Cargo.toml"),
310            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
311        )
312        .unwrap();
313        std::fs::write(
314            d.path().join("pyproject.toml"),
315            "[project]\nname = \"test\"\nversion = \"0.1.0\"\n",
316        )
317        .unwrap();
318        update_config_version(d.path(), "0.2.0");
319        let cargo = std::fs::read_to_string(d.path().join("Cargo.toml")).unwrap();
320        assert!(cargo.contains("version = \"0.2.0\""));
321        let pyproject = std::fs::read_to_string(d.path().join("pyproject.toml")).unwrap();
322        assert!(pyproject.contains("version = \"0.2.0\""));
323    }
324
325    #[test]
326    fn test_publish_scoped_version_with_contract() {
327        let d = tempfile::tempdir().unwrap();
328        git_init(d.path());
329        git_commit(d.path(), "init");
330
331        let contract_dir = d.path().join(".quanttide/devops");
332        std::fs::create_dir_all(&contract_dir).unwrap();
333        std::fs::write(
334            contract_dir.join("contract.yaml"),
335            "scopes:\n  cli:\n    dir: packages/cli\n    language: rust\n",
336        )
337        .unwrap();
338
339        let scope_dir = d.path().join("packages/cli");
340        std::fs::create_dir_all(&scope_dir).unwrap();
341        std::fs::write(
342            scope_dir.join("Cargo.toml"),
343            "[package]\nname = \"cli\"\nversion = \"0.1.0\"\n",
344        )
345        .unwrap();
346        std::fs::write(
347            scope_dir.join("CHANGELOG.md"),
348            "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n",
349        )
350        .unwrap();
351
352        // git add + commit 所有文件
353        std::process::Command::new("git")
354            .args(["add", "."])
355            .current_dir(d.path())
356            .output()
357            .unwrap();
358        std::process::Command::new("git")
359            .args(["commit", "-m", "setup scope"])
360            .current_dir(d.path())
361            .output()
362            .unwrap();
363
364        let result = publish("cli/v0.2.0", d.path(), true, false, None);
365        assert!(result.is_ok(), "publish 失败: {:?}", result.err());
366
367        let cargo = std::fs::read_to_string(scope_dir.join("Cargo.toml")).unwrap();
368        assert!(cargo.contains("version = \"0.2.0\""));
369    }
370}