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