Compare commits

..

2 Commits

Author SHA1 Message Date
7c87b86f9e Merge pull request 'feat(cicd): 让 CI 模板消费项目工具链契约' (#16) from feat/toolchain-capability-pack-t9 into master
All checks were successful
Push & PR Check / check (push) Successful in 55s
Reviewed-on: #16
2026-09-22 21:53:48 +08:00
lanrtop
9bf2d579ea feat(cicd): align templates with project toolchain
All checks were successful
Push & PR Check / check (push) Successful in 57s
Push & PR Check / check (pull_request) Successful in 56s
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
2026-09-22 22:38:06 +09:00
5 changed files with 262 additions and 49 deletions

View File

@ -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:
# 前端依赖

View File

@ -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 以消费者角色完成最终闭环。

View File

@ -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

View File

@ -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<String>,
pnpm_version: Option<String>,
node_source: String,
pnpm_source: String,
}
impl WorkflowToolchain {
fn from_project(root: &Path, cfg: &CicdConfig) -> Result<Self, String> {
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::<Vec<_>>()
.join(", ");
format!("{} [{}]", declaration.tool, candidates)
})
.collect::<Vec<_>>()
.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<Option<CicdConfig>, 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<String>) {
fn build_web_ssh_workflow(
cfg: &CicdConfig,
toolchain: &WorkflowToolchain,
) -> (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 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<String>) {
let node = cfg.node_version.as_deref().unwrap_or("20");
fn build_tauri_oss_workflow(
cfg: &CicdConfig,
toolchain: &WorkflowToolchain,
) -> (String, String, Vec<String>) {
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<String>) {
fn build_pr_ci_workflow(
cfg: &CicdConfig,
toolchain: &WorkflowToolchain,
) -> (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 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();
}
}

View File

@ -166,10 +166,11 @@ export function CicdModal({
placeholder="main"
/>
<Field
label="Node.js 版本"
label="Node.js 兼容回退版本"
value={form.node_version ?? ""}
onChange={(v) => setForm((f) => ({ ...f, node_version: v }))}
placeholder="20"
hint="项目有 .nvmrc / .node-version 时优先使用项目声明"
/>
</div>
<Field