// CI/CD 配置管理与 Workflow 生成(Gitea Actions,语法兼容 GitHub Actions) // 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案) use crate::db; use crate::models::{CicdConfig, WorkflowResult}; use rusqlite::params; use std::path::Path; use uuid::Uuid; #[tauri::command] pub fn get_cicd_config(project_id: String) -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let result = conn.query_row( "SELECT id, project_id, config_type, trigger_branch, build_command, node_version, server_host, server_user, deploy_path, start_command, oss_endpoint, created_at FROM cicd_configs WHERE project_id = ?1", params![project_id], CicdConfig::from_row, ); match result { Ok(cfg) => Ok(Some(cfg)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.to_string()), } } #[tauri::command] pub fn save_cicd_config(input: CicdConfig) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let id = if input.id.is_empty() { Uuid::new_v4().to_string() } else { input.id.clone() }; conn.execute( "INSERT INTO cicd_configs (id, project_id, config_type, trigger_branch, build_command, node_version, server_host, server_user, deploy_path, start_command, oss_endpoint) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11) ON CONFLICT(project_id) DO UPDATE SET config_type = excluded.config_type, trigger_branch = excluded.trigger_branch, build_command = excluded.build_command, node_version = excluded.node_version, server_host = excluded.server_host, server_user = excluded.server_user, deploy_path = excluded.deploy_path, start_command = excluded.start_command, oss_endpoint = excluded.oss_endpoint", params![ id, input.project_id, input.config_type, input.trigger_branch, input.build_command, input.node_version, input.server_host, input.server_user, input.deploy_path, input.start_command, input.oss_endpoint, ], ) .map_err(|e| e.to_string())?; get_cicd_config(input.project_id)?.ok_or_else(|| "save failed".to_string()) } /// 根据项目的 cicd_configs 生成 Gitea Actions workflow 文件,写入 {project_path}/.gitea/workflows/ /// project_path 必须是 Windows 可访问的路径(WSL 项目请传 UNC 路径 \\wsl.localhost\...) #[tauri::command] pub fn generate_workflow( project_id: String, project_path: String, ) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let config = conn .query_row( "SELECT id, project_id, config_type, trigger_branch, build_command, node_version, server_host, server_user, deploy_path, start_command, oss_endpoint, created_at FROM cicd_configs WHERE project_id = ?1", params![project_id], CicdConfig::from_row, ) .map_err(|e| e.to_string())?; let (content, filename, secrets) = match config.config_type.as_str() { "tauri_oss" => build_tauri_oss_workflow(&config), "pr_ci" => build_pr_ci_workflow(&config), "deepseek_review" => build_deepseek_review_workflow(&config), _ => build_web_ssh_workflow(&config), }; let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?; let file_path = workflows_dir.join(&filename); std::fs::write(&file_path, &content).map_err(|e| e.to_string())?; Ok(WorkflowResult { content, path: file_path.to_string_lossy().to_string(), secrets, }) } /// MCP 用:按 workflow_type 直接分发 CI 模板,自动解析项目路径,无需预存 cicd_config。 /// workflow_type: "pr_ci" | "deepseek_review" pub fn distribute_workflow_for_project( project_id: &str, workflow_type: &str, ) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let (win_path, wsl_path, platform): (Option, Option, Option) = conn .query_row( "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", params![project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .map_err(|_| format!("项目 {} 不存在", project_id))?; let project_path = if platform.as_deref() == Some("wsl") { let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?; let distro: String = conn .query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0), ) .unwrap_or_else(|_| "Ubuntu".to_string()); let inner = wsl.trim_start_matches('/').replace('/', "\\"); format!("\\\\wsl.localhost\\{}\\{}", distro, inner) } else { win_path.ok_or("项目未配置本地路径")? }; // 最小 config,pr_ci 用 trigger_branch;deepseek_review 不使用 config 字段 let minimal_cfg = crate::models::CicdConfig { id: String::new(), project_id: project_id.to_string(), config_type: workflow_type.to_string(), trigger_branch: "main".to_string(), build_command: None, node_version: None, server_host: None, server_user: None, deploy_path: None, start_command: None, oss_endpoint: None, created_at: None, }; let (content, filename, secrets) = match workflow_type { "pr_ci" => build_pr_ci_workflow(&minimal_cfg), "deepseek_review" => build_deepseek_review_workflow(&minimal_cfg), other => { return Err(format!( "不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review)" )) } }; let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?; let file_path = workflows_dir.join(&filename); std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?; let secrets_hint = if secrets.is_empty() { String::new() } else { format!(",需要配置 secrets: {}", secrets.join(" / ")) }; Ok(format!( "CI 模板已写入:.gitea/workflows/{filename}{secrets_hint}\n提交并推送后 CI 将自动触发。" )) } // ── workflow 模板生成 ────────────────────────────────────────────────────────── fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec) { let branch = &cfg.trigger_branch; let node = cfg.node_version.as_deref().unwrap_or("20"); let build = cfg.build_command.as_deref().unwrap_or("pnpm build"); let path = cfg.deploy_path.as_deref().unwrap_or("/app/project"); let start = cfg.start_command.as_deref().unwrap_or("pm2 restart app"); let content = format!( r#"# Gitea Actions(语法兼容 GitHub Actions,action 由 runner 从镜像源拉取) name: Deploy on: push: branches: [{branch}] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '{node}' cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: {build} - name: Deploy to server uses: appleboy/ssh-action@v1 with: host: ${{{{ secrets.SERVER_HOST }}}} username: ${{{{ secrets.SERVER_USER }}}} key: ${{{{ secrets.SSH_KEY }}}} script: | cd {path} git pull pnpm install --frozen-lockfile {build} {start} "# ); let secrets = vec![ "SERVER_HOST".to_string(), "SERVER_USER".to_string(), "SSH_KEY".to_string(), ]; (content, "deploy.yml".to_string(), secrets) } fn build_tauri_oss_workflow(cfg: &CicdConfig) -> (String, String, Vec) { let node = cfg.node_version.as_deref().unwrap_or("20"); let endpoint = cfg .oss_endpoint .as_deref() .unwrap_or("oss-cn-hangzhou.aliyuncs.com"); let content = format!( r#"# Tauri v2 Windows 自动构建 + 签名 + 阿里云 OSS 发布(Gitea Actions) # 需要一台带 'windows' 标签的自建 Windows runner(act_runner) # 发布产物走 OSS 分发(tauri-plugin-updater 读 latest.json),不依赖 Release 页面 name: Release on: push: tags: - 'v*' jobs: release: runs-on: windows steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '{node}' cache: 'pnpm' - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Rust cache uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' - run: pnpm install --frozen-lockfile - name: Verify signing key shell: pwsh env: TAURI_SIGNING_PRIVATE_KEY: ${{{{ secrets.TAURI_SIGNING_PRIVATE_KEY }}}} run: | if ([string]::IsNullOrEmpty($env:TAURI_SIGNING_PRIVATE_KEY)) {{ Write-Error "TAURI_SIGNING_PRIVATE_KEY is EMPTY - update bundles will not be generated!" exit 1 }} $lines = $env:TAURI_SIGNING_PRIVATE_KEY.Split("`n").Count Write-Host "TAURI_SIGNING_PRIVATE_KEY is set ($lines lines)" - name: Build(签名随环境变量注入) shell: pwsh env: TAURI_SIGNING_PRIVATE_KEY: ${{{{ secrets.TAURI_SIGNING_PRIVATE_KEY }}}} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{{{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}}} run: pnpm tauri build --verbose - name: Upload to OSS & generate latest.json shell: pwsh env: OSS_KEY_ID: ${{{{ secrets.OSS_KEY_ID }}}} OSS_KEY_SECRET: ${{{{ secrets.OSS_KEY_SECRET }}}} OSS_BUCKET: ${{{{ secrets.OSS_BUCKET }}}} OSS_ENDPOINT: ${{{{ secrets.OSS_ENDPOINT }}}} run: | # 下载 ossutil $oss = "$env:GITHUB_WORKSPACE\ossutil64.exe" Invoke-WebRequest -Uri "https://gosspublic.alicdn.com/ossutil/1.7.17/ossutil-v1.7.17-windows-amd64.zip" -OutFile oss.zip Expand-Archive oss.zip -DestinationPath oss_tmp $exeFound = Get-ChildItem oss_tmp -Filter "ossutil*.exe" -Recurse | Select-Object -First 1 Copy-Item $exeFound.FullName $oss $tag = "${{{{ github.ref_name }}}}" $version = $tag.TrimStart('v') $nsisDir = "$env:GITHUB_WORKSPACE\src-tauri\target\release\bundle\nsis" Write-Host "=== bundle dir ===" Get-ChildItem "$env:GITHUB_WORKSPACE\src-tauri\target\release\bundle" -Recurse | ForEach-Object {{ Write-Host $_.FullName }} $all = Get-ChildItem $nsisDir $installer = $all | Where-Object {{ $_.Name.EndsWith('-setup.exe') }} | Select-Object -First 1 $sigFile = $all | Where-Object {{ $_.Name.EndsWith('-setup.exe.sig') }} | Select-Object -First 1 if (-not $installer) {{ Write-Error "installer not found!"; exit 1 }} $bucket = "oss://$env:OSS_BUCKET" $endpoint = "https://$env:OSS_ENDPOINT" & $oss config -e $endpoint -i $env:OSS_KEY_ID -k $env:OSS_KEY_SECRET -L CH & $oss cp $installer.FullName "$bucket/releases/$tag/$($installer.Name)" -f if ($sigFile) {{ & $oss cp $sigFile.FullName "$bucket/releases/$tag/$($sigFile.Name)" -f $date = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') $baseUrl = "https://$env:OSS_BUCKET.{endpoint}/releases/$tag" $sig = (Get-Content $sigFile.FullName -Raw).Trim() $json = [ordered]@{{ version = $version pub_date = $date notes = "版本 $tag 已发布" platforms = [ordered]@{{ 'windows-x86_64' = [ordered]@{{ signature = $sig url = "$baseUrl/$($installer.Name)" }} }} }} | ConvertTo-Json -Depth 5 $json | Out-File -FilePath latest.json -Encoding utf8NoBOM Write-Host "=== latest.json ===" Get-Content latest.json & $oss cp latest.json "$bucket/latest.json" -f Write-Host "latest.json uploaded" }} else {{ Write-Warning "sigFile (.exe.sig) not found - skipping updater upload" }} "# ); let secrets = vec![ "TAURI_SIGNING_PRIVATE_KEY".to_string(), "TAURI_SIGNING_PRIVATE_KEY_PASSWORD".to_string(), "OSS_KEY_ID".to_string(), "OSS_KEY_SECRET".to_string(), "OSS_BUCKET".to_string(), "OSS_ENDPOINT".to_string(), ]; (content, "release.yml".to_string(), secrets) } fn build_pr_ci_workflow(cfg: &CicdConfig) -> (String, String, Vec) { let branch = &cfg.trigger_branch; let node = cfg.node_version.as_deref().unwrap_or("20"); let build_cmd = cfg.build_command.as_deref().unwrap_or("pnpm build"); let content = format!( r#"# PR 代码质量检查(Gitea Actions) # 在 PR 合并前执行 build / lint,确保代码可编译且风格合规 name: PR Check on: pull_request: branches: [{branch}] jobs: check: runs-on: windows steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '{node}' cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: {build_cmd} - run: pnpm run --if-present lint - run: pnpm run --if-present test --passWithNoTests "# ); (content, "pr-check.yml".to_string(), vec![]) } fn build_deepseek_review_workflow(_cfg: &CicdConfig) -> (String, String, Vec) { let content = r#"# AI 代码审查(DeepSeek + Gitea Actions) # PR 开启时自动调用 DeepSeek 生成审查报告,以评论形式发布到 Gitea PR name: AI Code Review on: pull_request: types: [opened, synchronize] jobs: ai-review: runs-on: windows steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: DeepSeek Code Review env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} LJ_TOKEN: ${{ secrets.LJ_TOKEN }} LJ_URL: ${{ secrets.LJ_URL }} PR_NUMBER: ${{ github.event.pull_request.number }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPO: ${{ github.repository }} shell: bash run: | python3 << 'PYEOF' import os, sys, json, urllib.request, subprocess api_key = os.environ["DEEPSEEK_API_KEY"] token = os.environ["LJ_TOKEN"] base_url = os.environ["LJ_URL"].rstrip("/") pr_num = os.environ["PR_NUMBER"] base_sha = os.environ["BASE_SHA"] head_sha = os.environ["HEAD_SHA"] repo = os.environ["REPO"] diff = subprocess.check_output( ["git", "diff", f"{base_sha}...{head_sha}", "--diff-filter=AM", "-p"], text=True )[:8000] if not diff.strip(): print("diff 为空,跳过审查") sys.exit(0) prompt = f"""请审查以下 PR diff,简洁指出潜在 bug、安全风险或可维护性问题。 用中文回复,每条意见以 - 开头,无问题则回复"LGTM 无明显问题"。 diff: {diff}""" req_data = json.dumps({ "model": "deepseek-chat", "messages": [{"role": "user", "content": prompt}], "max_tokens": 800 }).encode() req = urllib.request.Request( "https://api.deepseek.com/v1/chat/completions", data=req_data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}" }, method="POST" ) with urllib.request.urlopen(req, timeout=60) as res: body = json.load(res) review = body["choices"][0]["message"]["content"] owner, repo_name = repo.split("/", 1) comment = f"🤖 **AI 代码审查(DeepSeek)**\n\n{review}\n\n---\n*由炼境自动触发*" req_data = json.dumps({"body": comment}).encode() req = urllib.request.Request( f"{base_url}/api/v1/repos/{owner}/{repo_name}/issues/{pr_num}/comments", data=req_data, headers={ "Content-Type": "application/json", "Authorization": f"token {token}" }, method="POST" ) with urllib.request.urlopen(req) as _: pass print("AI 审查评论已发布") PYEOF "# .to_string(); let secrets = vec![ "DEEPSEEK_API_KEY".to_string(), "LJ_TOKEN".to_string(), "LJ_URL".to_string(), ]; (content, "ai-review.yml".to_string(), secrets) } #[cfg(test)] mod tests { use super::*; fn cfg(config_type: &str) -> CicdConfig { CicdConfig { id: "t".into(), project_id: "p".into(), config_type: config_type.into(), trigger_branch: "main".into(), build_command: Some("pnpm build".into()), node_version: Some("20".into()), server_host: Some("host".into()), server_user: Some("ubuntu".into()), deploy_path: Some("/app".into()), start_command: Some("pm2 restart app".into()), oss_endpoint: Some("oss-cn-hangzhou.aliyuncs.com".into()), created_at: None, } } #[test] fn web_ssh_template_has_no_github_residue() { let (content, filename, secrets) = build_web_ssh_workflow(&cfg("web_ssh")); assert!(!content.contains("github.com"), "模板残留 github.com 引用"); assert!(!content.contains("GITHUB_TOKEN"), "模板残留 GITHUB_TOKEN"); assert_eq!(filename, "deploy.yml"); assert!(secrets.contains(&"SSH_KEY".to_string())); } #[test] fn tauri_oss_template_has_no_github_residue() { let (content, filename, secrets) = build_tauri_oss_workflow(&cfg("tauri_oss")); assert!(!content.contains("github.com"), "模板残留 github.com 引用"); assert!(!content.contains("GITHUB_TOKEN"), "模板残留 GITHUB_TOKEN"); assert!( !content.contains("gh-release"), "模板残留 GitHub Release 步骤" ); assert!(content.contains("pnpm tauri build"), "缺少构建步骤"); assert!(content.contains("latest.json"), "缺少 updater 产物上传"); assert_eq!(filename, "release.yml"); assert!(secrets.contains(&"TAURI_SIGNING_PRIVATE_KEY".to_string())); assert!( !secrets.iter().any(|s| s.contains("GITHUB")), "secrets 残留 GitHub 项" ); } #[test] fn pr_ci_template_is_correct() { let (content, filename, secrets) = build_pr_ci_workflow(&cfg("pr_ci")); assert!(content.contains("pull_request"), "触发器应为 pull_request"); assert!(content.contains("pnpm build"), "应含构建步骤"); // --if-present 只对 pnpm run 子命令有效,直接 `pnpm lint --if-present` 会把 // lint 当外部命令执行报 ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL(炼境自身 CI 踩过) assert!( content.contains("pnpm run --if-present lint"), "lint 步骤须用 pnpm run --if-present 语法" ); assert!( content.contains("pnpm run --if-present test"), "test 步骤须用 pnpm run --if-present 语法" ); assert!(!content.contains("SERVER_HOST"), "不应含 SSH 部署配置"); assert!(!content.contains("ssh-action"), "不应含 SSH Action"); assert_eq!(filename, "pr-check.yml"); assert!(secrets.is_empty(), "PR CI 不需要 secrets"); } #[test] fn deepseek_review_template_is_correct() { let (content, filename, secrets) = build_deepseek_review_workflow(&cfg("deepseek_review")); assert!( content.contains("DEEPSEEK_API_KEY"), "缺少 DeepSeek API key 引用" ); assert!(content.contains("pull_request"), "触发器应为 pull_request"); assert!(content.contains("deepseek-chat"), "缺少模型名称"); assert_eq!(filename, "ai-review.yml"); assert!(secrets.contains(&"DEEPSEEK_API_KEY".to_string())); assert!(secrets.contains(&"LJ_TOKEN".to_string())); assert!(secrets.contains(&"LJ_URL".to_string())); } }