600 lines
21 KiB
Rust
600 lines
21 KiB
Rust
// 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()));
|
||
}
|
||
}
|