dev-manager-tauri/src-tauri/src/commands/cicd.rs
2026-09-21 12:52:37 +09:00

600 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Option<CicdConfig>, 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<CicdConfig, String> {
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<WorkflowResult, String> {
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<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = 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<String>) {
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<String>) {
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<String>) {
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<String>) {
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()));
}
}