From 9bf2d579eaa6841382712d4bd8a6f01b37b242f8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 22:38:06 +0900 Subject: [PATCH] feat(cicd): align templates with project toolchain Reuse the canonical Node/pnpm contract when generating workflows, preserve per-tool legacy fallbacks, and reject conflicting declarations with evidence. Feature-Confirmed: true Rules-Applied: R02,R12 --- .blueprint/manifest.yaml | 6 +- .../modules/toolchain-capability-pack.md | 10 +- docs/tauri-cicd-guide.md | 5 +- src-tauri/src/commands/cicd.rs | 287 +++++++++++++++--- src/components/dashboard/CicdModal.tsx | 3 +- 5 files changed, 262 insertions(+), 49 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index fa6b952..543f53b 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -472,10 +472,10 @@ modules: - id: toolchain-capability-pack name: 项目工具链能力包 area: infra - status: done - progress: 100 + status: in_progress + progress: 89 updated: 2026-09-22 - note: "v1 与 T8 自洽补强均已闭环:炼境自身通过 CLI、真实 MCP 与 Gitea push/PR CI 验证 Node 24.19.0、pnpm 9.15.0 同源生效;CI 双版本来源已修复,PR #15 经安全门禁合并。不追随 Codex bundled runtime,明确不支持 WSL;通用 CI 分发模板兼容迁移另立任务。" + note: "v1 与 T8 自洽补强均已闭环;T9 正在将通用 CI 分发模板迁移为优先消费项目 `.nvmrc` / `packageManager`、缺失时兼容旧项目回退的同源 adapter。" position: [2500, 950] edges: # 前端依赖 diff --git a/.blueprint/modules/toolchain-capability-pack.md b/.blueprint/modules/toolchain-capability-pack.md index 0440c07..1fdc80a 100644 --- a/.blueprint/modules/toolchain-capability-pack.md +++ b/.blueprint/modules/toolchain-capability-pack.md @@ -141,8 +141,16 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b - done_note: "2026-09-22:炼境自身新增 `.nvmrc` 24.19.0、packageManager pnpm@9.15.0 与最小 mise resolver。CLI 命中 mise 安装目录,typecheck 通过、Rust 151/151;真实 MCP get_toolchain_status 验收 Node/pnpm 均为 ready_managed,pnpm-lock.yaml 零改动。首次 push CI #120 精确暴露 pnpm 双版本来源,修复后 push #122 与 PR #123 均成功,PR #15 以 head 23e9c297caad897c7917776308ca35a8014941df 经安全门禁合并为 96f88a8。" - retrospective: "【复盘】炼境自身工具链契约闭环|任务类型: 架构调整|复杂度: M|实际轮数: 3轮(预估 1 轮)|是否返工: 是|返工原因: Gitea workflow 同时硬编码 pnpm 9 与读取 packageManager,pnpm/action-setup 拒绝双版本来源|有效策略: 本地与 MCP 后继续让真实 push/PR CI 消费契约,门禁暴露 adapter 漂移并以单一事实来源修复|遗留风险: distribute_cicd 的通用 web_ssh/tauri_oss/pr_ci 模板仍硬编码 pnpm 9 与默认 Node 20,需另立兼容旧项目的任务,不能混入“炼境自身”T8|auto_applied_rules: R02, R11, R13|rule_effectiveness: R02=有效, R11=有效, R13=有效" +### 🔵 T9. 通用 CI 分发模板消费项目工具链契约 +- status: in_progress +- complexity: M +- depends: T1. Node/pnpm 工具链契约解析器, T8. 炼境自身工具链契约闭环 +- files: src-tauri/src/commands/cicd.rs, src/components/dashboard/CicdModal.tsx, docs/tauri-cicd-guide.md, .blueprint/modules/toolchain-capability-pack.md, .blueprint/manifest.yaml +- acceptance: "agent_verify: [cargo test] 临时项目提供 `.nvmrc` + package.json#packageManager 时,web_ssh/tauri_oss/pr_ci 模板使用 node-version-file 且 pnpm/action-setup 不重复写 version;只有部分声明时逐工具消费契约,完全缺失时保持已保存 Node 版本或 Node 20 + pnpm 9 的旧项目回退;冲突声明拒绝生成并返回证据;`cargo test --lib` 与 `pnpm typecheck` 通过,`git diff -- pnpm-lock.yaml` 为空。human_verify: CI/CD 模态框明确标注 Node 输入仅为无项目声明时的兼容回退版本。" +- decision_note: "CI adapter 复用 parse_toolchain_contract,不复制解析规则;项目声明是第一事实源,CicdConfig 只保留旧项目回退语义。" + ## 执行顺序 -T1 → T2 → T3;T2 → T4 → T5;T3 + T4 → T6;T3 + T4 + T6 → T7 → T8。 +T1 → T2 → T3;T2 → T4 → T5;T3 + T4 → T6;T3 + T4 + T6 → T7 → T8 → T9。 核心能力先只读、后受控写;炼境实现者验证完成后,必须由 Enterprise System 以消费者角色完成最终闭环。 diff --git a/docs/tauri-cicd-guide.md b/docs/tauri-cicd-guide.md index 3ba58a7..77cad78 100644 --- a/docs/tauri-cicd-guide.md +++ b/docs/tauri-cicd-guide.md @@ -192,14 +192,13 @@ jobs: steps: - uses: actions/checkout@v4 + # package.json#packageManager 是 pnpm 版本事实源;不要在此重复写 version - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install Rust diff --git a/src-tauri/src/commands/cicd.rs b/src-tauri/src/commands/cicd.rs index 9732d11..1b167d7 100644 --- a/src-tauri/src/commands/cicd.rs +++ b/src-tauri/src/commands/cicd.rs @@ -1,11 +1,123 @@ // CI/CD 配置管理与 Workflow 生成(Gitea Actions,语法兼容 GitHub Actions) // 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案) +use crate::commands::toolchain::parse_toolchain_contract; use crate::db; use crate::models::{CicdConfig, WorkflowResult}; use rusqlite::params; use std::path::Path; use uuid::Uuid; +#[derive(Debug, Clone, PartialEq, Eq)] +struct WorkflowToolchain { + node_version: String, + node_version_file: Option, + pnpm_version: Option, + node_source: String, + pnpm_source: String, +} + +impl WorkflowToolchain { + fn from_project(root: &Path, cfg: &CicdConfig) -> Result { + let contract = parse_toolchain_contract(root)?; + if !contract.conflicts.is_empty() { + let evidence = [&contract.node, &contract.pnpm] + .into_iter() + .filter(|declaration| declaration.state == "conflict") + .map(|declaration| { + let candidates = declaration + .candidates + .iter() + .filter(|candidate| candidate.exact) + .map(|candidate| format!("{}={}", candidate.source, candidate.version)) + .collect::>() + .join(", "); + format!("{} [{}]", declaration.tool, candidates) + }) + .collect::>() + .join(";"); + return Err(format!( + "项目工具链声明冲突,拒绝生成 CI workflow:{evidence}" + )); + } + + let fallback_node = cfg + .node_version + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("20"); + let node_version = contract + .node + .version + .clone() + .unwrap_or_else(|| fallback_node.to_string()); + let node_version_file = contract + .node + .source + .as_deref() + .filter(|source| matches!(*source, ".nvmrc" | ".node-version")) + .map(str::to_string); + let node_source = contract + .node + .source + .clone() + .unwrap_or_else(|| "CicdConfig fallback".to_string()); + + let package_manager_is_source = + contract.pnpm.source.as_deref() == Some("package.json#packageManager"); + let pnpm_version = if package_manager_is_source { + None + } else { + Some( + contract + .pnpm + .version + .clone() + .unwrap_or_else(|| "9".to_string()), + ) + }; + let pnpm_source = contract + .pnpm + .source + .clone() + .unwrap_or_else(|| "legacy pnpm 9 fallback".to_string()); + + Ok(Self { + node_version, + node_version_file, + pnpm_version, + node_source, + pnpm_source, + }) + } + + fn setup_steps(&self) -> String { + let pnpm_version = self + .pnpm_version + .as_ref() + .map(|version| format!("\n with:\n version: '{version}'")) + .unwrap_or_default(); + let node_selector = self + .node_version_file + .as_ref() + .map(|path| format!("node-version-file: '{path}'")) + .unwrap_or_else(|| format!("node-version: '{}'", self.node_version)); + + format!( + r#" # 工具链来源:Node={node_source};pnpm={pnpm_source} + - uses: pnpm/action-setup@v4{pnpm_version} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + {node_selector} + cache: 'pnpm'"#, + node_source = self.node_source, + pnpm_source = self.pnpm_source, + ) + } +} + #[tauri::command] pub fn get_cicd_config(project_id: String) -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; @@ -83,14 +195,24 @@ pub fn generate_workflow( ) .map_err(|e| e.to_string())?; + let project_root = Path::new(&project_path); let (content, filename, secrets) = match config.config_type.as_str() { - "tauri_oss" => build_tauri_oss_workflow(&config), - "pr_ci" => build_pr_ci_workflow(&config), + "tauri_oss" => { + let toolchain = WorkflowToolchain::from_project(project_root, &config)?; + build_tauri_oss_workflow(&config, &toolchain) + } + "pr_ci" => { + let toolchain = WorkflowToolchain::from_project(project_root, &config)?; + build_pr_ci_workflow(&config, &toolchain) + } "deepseek_review" => build_deepseek_review_workflow(&config), - _ => build_web_ssh_workflow(&config), + _ => { + let toolchain = WorkflowToolchain::from_project(project_root, &config)?; + build_web_ssh_workflow(&config, &toolchain) + } }; - let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); + let workflows_dir = project_root.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())?; @@ -150,7 +272,11 @@ pub fn distribute_workflow_for_project( }; let (content, filename, secrets) = match workflow_type { - "pr_ci" => build_pr_ci_workflow(&minimal_cfg), + "pr_ci" => { + let toolchain = + WorkflowToolchain::from_project(Path::new(&project_path), &minimal_cfg)?; + build_pr_ci_workflow(&minimal_cfg, &toolchain) + } "deepseek_review" => build_deepseek_review_workflow(&minimal_cfg), other => { return Err(format!( @@ -177,12 +303,15 @@ pub fn distribute_workflow_for_project( // ── workflow 模板生成 ────────────────────────────────────────────────────────── -fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec) { +fn build_web_ssh_workflow( + cfg: &CicdConfig, + toolchain: &WorkflowToolchain, +) -> (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 setup_steps = toolchain.setup_steps(); let content = format!( r#"# Gitea Actions(语法兼容 GitHub Actions,action 由 runner 从镜像源拉取) @@ -198,15 +327,7 @@ jobs: 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' +{setup_steps} - run: pnpm install --frozen-lockfile - run: {build} @@ -234,12 +355,15 @@ jobs: (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"); +fn build_tauri_oss_workflow( + cfg: &CicdConfig, + toolchain: &WorkflowToolchain, +) -> (String, String, Vec) { let endpoint = cfg .oss_endpoint .as_deref() .unwrap_or("oss-cn-hangzhou.aliyuncs.com"); + let setup_steps = toolchain.setup_steps(); let content = format!( r#"# Tauri v2 Windows 自动构建 + 签名 + 阿里云 OSS 发布(Gitea Actions) @@ -258,15 +382,7 @@ jobs: 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' +{setup_steps} - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -373,10 +489,13 @@ jobs: (content, "release.yml".to_string(), secrets) } -fn build_pr_ci_workflow(cfg: &CicdConfig) -> (String, String, Vec) { +fn build_pr_ci_workflow( + cfg: &CicdConfig, + toolchain: &WorkflowToolchain, +) -> (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 setup_steps = toolchain.setup_steps(); let content = format!( r#"# PR 代码质量检查(Gitea Actions) @@ -393,15 +512,7 @@ jobs: 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' +{setup_steps} - run: pnpm install --frozen-lockfile - run: {build_cmd} @@ -515,6 +626,7 @@ diff: #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; fn cfg(config_type: &str) -> CicdConfig { CicdConfig { @@ -533,9 +645,30 @@ mod tests { } } + fn legacy_toolchain() -> WorkflowToolchain { + WorkflowToolchain { + node_version: "20".into(), + node_version_file: None, + pnpm_version: Some("9".into()), + node_source: "CicdConfig fallback".into(), + pnpm_source: "legacy pnpm 9 fallback".into(), + } + } + + fn temp_project(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "lian-jing-cicd-{name}-{}-{}", + std::process::id(), + Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + root + } + #[test] fn web_ssh_template_has_no_github_residue() { - let (content, filename, secrets) = build_web_ssh_workflow(&cfg("web_ssh")); + let (content, filename, secrets) = + build_web_ssh_workflow(&cfg("web_ssh"), &legacy_toolchain()); assert!(!content.contains("github.com"), "模板残留 github.com 引用"); assert!(!content.contains("GITHUB_TOKEN"), "模板残留 GITHUB_TOKEN"); assert_eq!(filename, "deploy.yml"); @@ -544,7 +677,8 @@ mod tests { #[test] fn tauri_oss_template_has_no_github_residue() { - let (content, filename, secrets) = build_tauri_oss_workflow(&cfg("tauri_oss")); + let (content, filename, secrets) = + build_tauri_oss_workflow(&cfg("tauri_oss"), &legacy_toolchain()); assert!(!content.contains("github.com"), "模板残留 github.com 引用"); assert!(!content.contains("GITHUB_TOKEN"), "模板残留 GITHUB_TOKEN"); assert!( @@ -563,7 +697,7 @@ mod tests { #[test] fn pr_ci_template_is_correct() { - let (content, filename, secrets) = build_pr_ci_workflow(&cfg("pr_ci")); + let (content, filename, secrets) = build_pr_ci_workflow(&cfg("pr_ci"), &legacy_toolchain()); assert!(content.contains("pull_request"), "触发器应为 pull_request"); assert!(content.contains("pnpm build"), "应含构建步骤"); // --if-present 只对 pnpm run 子命令有效,直接 `pnpm lint --if-present` 会把 @@ -596,4 +730,75 @@ mod tests { assert!(secrets.contains(&"LJ_TOKEN".to_string())); assert!(secrets.contains(&"LJ_URL".to_string())); } + + #[test] + fn templates_consume_standard_project_toolchain_contract() { + let root = temp_project("standard-contract"); + std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"packageManager":"pnpm@9.15.0"}"#, + ) + .unwrap(); + + let config = cfg("pr_ci"); + let toolchain = WorkflowToolchain::from_project(&root, &config).unwrap(); + let contents = [ + build_web_ssh_workflow(&config, &toolchain).0, + build_tauri_oss_workflow(&config, &toolchain).0, + build_pr_ci_workflow(&config, &toolchain).0, + ]; + + for content in contents { + assert!(content.contains("node-version-file: '.nvmrc'")); + assert!(content.contains("pnpm=package.json#packageManager")); + assert!(!content.contains("version: '9'")); + assert!(!content.contains("node-version: '20'")); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn toolchain_contract_falls_back_per_missing_tool() { + let root = temp_project("partial-contract"); + std::fs::write(root.join(".nvmrc"), "22.14.0\n").unwrap(); + + let config = cfg("pr_ci"); + let toolchain = WorkflowToolchain::from_project(&root, &config).unwrap(); + let content = build_pr_ci_workflow(&config, &toolchain).0; + + assert!(content.contains("node-version-file: '.nvmrc'")); + assert!(content.contains("version: '9'")); + assert!(content.contains("pnpm=legacy pnpm 9 fallback")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn legacy_project_keeps_configured_node_and_pnpm_fallback() { + let root = temp_project("legacy-contract"); + let mut config = cfg("pr_ci"); + config.node_version = Some("18.20.8".into()); + + let toolchain = WorkflowToolchain::from_project(&root, &config).unwrap(); + let content = build_pr_ci_workflow(&config, &toolchain).0; + + assert!(content.contains("node-version: '18.20.8'")); + assert!(content.contains("version: '9'")); + assert!(content.contains("Node=CicdConfig fallback")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn conflicting_project_contract_blocks_workflow_generation() { + let root = temp_project("conflicting-contract"); + std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap(); + std::fs::write(root.join(".node-version"), "22.14.0\n").unwrap(); + + let error = WorkflowToolchain::from_project(&root, &cfg("pr_ci")).unwrap_err(); + + assert!(error.contains("拒绝生成 CI workflow")); + assert!(error.contains(".nvmrc=24.19.0")); + assert!(error.contains(".node-version=22.14.0")); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/components/dashboard/CicdModal.tsx b/src/components/dashboard/CicdModal.tsx index 6076c89..5f02faf 100644 --- a/src/components/dashboard/CicdModal.tsx +++ b/src/components/dashboard/CicdModal.tsx @@ -166,10 +166,11 @@ export function CicdModal({ placeholder="main" /> setForm((f) => ({ ...f, node_version: v }))} placeholder="20" + hint="项目有 .nvmrc / .node-version 时优先使用项目声明" />