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
All checks were successful
Push & PR Check / check (push) Successful in 55s
Reviewed-on: #16
This commit is contained in:
commit
7c87b86f9e
@ -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:
|
||||
# 前端依赖
|
||||
|
||||
@ -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 以消费者角色完成最终闭环。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user