
自托管一个 CI/CD 工具,迟早会撞上一个哲学问题:流水线的定义,到底放在哪儿?一派是「画布派」:打开网页,拖几个节点,连几条线,构建→测试→部署一目了然,改起来点点鼠标就行。Jenkins 的老式配置、很多国产 DevOps 平台都是这个路子。另一派是「代码派」:流水线就是仓库里的一个 yaml 文件,.github/workflows/*.yml、.gitlab-ci.yml、.pipewright.yml……跟代码一起提交、一起 review、一起回滚。GitHub Actions 把这一派彻底带火了。这两派各有各的爽点,也各有各的死穴。我做自己那个部署工具的时候,一开始只做了画布,用着用着发现画布有个绕不过去的坎;后来把 yaml 也加上,又踩进「两套定义怎么共存」的坑里。这篇把两种范式的取舍、以及「同时支持两种」时那个最容易翻车的机制,讲清楚。一、画布的爽与痛画布(可视化编排)的爽是真的爽:零学习成本:不用记 yaml 字段,拖拽就会,新人上手快。所见即所得:阶段之间的依赖(谁在谁之后、哪些并行)画成图,一眼看懂,比读一坨缩进的 yaml 直观。改一下立刻能跑:在网页上调个参数,点运行,不用 commit、不用 push。但画布有两个绕不过去的痛:配置不在版本控制里。你在网页上改了流水线,谁改的、改了啥、为什么改——没记录。出了事想回到「上周那个能跑的版本」,对不起,画布没有git log。配置和代码分家。代码在仓库里,流水线在数据库里,两个东西各活各的。一个 feature 分支想临时改改构建步骤?画布做不到——画布是项目级的一套配置,不跟分支走。你在 main 上改了画布,feature 分支也被连累。第 2 点尤其致命。GitHub Actions 之所以好用,一大半功劳就在:workflow 文件跟着分支走。你在 feature 分支改 CI,只影响这个分支,合并前 main 纹丝不动。这是画布给不了的。二、代码派的答案:把流水线写进.pipewright.yml代码派的解法很直接——流水线就是仓库根目录的一个 yaml 文件。以我的工具为例,长这样:version:1stages:-id:stg_srcname:拉源码kind:sourcejobs:-{id:job_src,name:源,type:git_source}-id:stg_buildname:构建kind:buildneeds:[stg_src]# DAG 依赖:构建在拉源码之后jobs:-id:job_compilename:compiletype:scriptscript:image:golang:1.24commands:-go build ./...-id:stg_deployname:部署kind:deploy_sshneeds:[stg_build]gate:true# 人工审批门:部署前卡一下when:branches:[main]# 只在 main 分支部署这里要特意和「GitOps 部署」划清界限——很多人一提「yaml git」就想到 docker-compose。但这两个是不同层面的东西:docker-compose.yml描述的是「部署产物长什么样」:哪些服务、端口、卷、网络。.pipewright.yml描述的是「流水线怎么编排」:先拉源码、再构建、再部署,阶段之间谁依赖谁、哪个要人工审批、哪个只在 main 跑。前者是「终态」,后者是「过程」。.pipewright.yml里的deploy_ssh步骤可以去执行docker-compose up,但那是它的一个动作,不是同一层的东西。别把两者混为一谈。一旦流水线进了 yaml,画布那两个痛点全好了:配置进版本控制(有git log、能 review、能回滚)、配置跟分支走(feature 分支改 yaml 只影响自己)。三、真正的难题:两种范式怎么共存到这儿,一个偷懒的做法是「二选一」——要么全画布,要么全 yaml,逼用户站队。但现实是:老项目已经在画布上画好了,新需求想上 yaml,你不能让人推倒重来。所以真问题是:同一个项目,既有画布配置、又有仓库 yaml 时,一次运行到底听谁的?我的做法是一个每项目的开关(默认关):开关关(默认):100% 用画布配置,仓库里就算有.pipewright.yml也当没看见,完全不读。老项目行为一点不变。开关开:优先用仓库 yaml。运行时按这次运行的分支去仓库根目录读.pipewright.yml,解析出来驱动这一次运行。关键点有三个,每一个都值得说清楚:1. 这是「二选一」,不是「合并」。一次运行的流水线定义只来自一个源,要么是仓库 yaml(repo),要么是画布配置(stored),不存在把两边揉一起。揉一起是灾难——你永远说不清最终跑的是啥。选一个,清清楚楚。2. 按运行分支读,这是 yaml 派最大的价值。开关打开后,在 feature 分支改.pipewright.yml、推上去,这个分支的运行就按新 yaml 跑,main 分支不受影响。这就把画布给不了的「配置跟分支走」补上了。3. 出问题静默回退,运行永不中断。yaml 文件不存在?格式错了?是个二进制?——一律自动回退到画布配置,这次运行照常跑,不会因为 yaml 写错就把流水线卡死。这是刻意的设计:流水线即代码是「增强」,不该变成「新的单点故障」。四、回退是安全网,但也是个坑「静默回退」听起来很贴心,但它有个反面:静默——回退的时候不吭声。想象这个场景:你以为运行在按仓库 yaml 跑,实际上 yaml 里有个缩进错误,系统悄悄回退到了画布的老配置。运行绿了,你还以为新 yaml 生效了,其实压根没跑到。所以我加了一个预览端点:在真跑之前,你可以让系统去仓库把.pipewright.yml拉出来、解析一遍、告诉你「找到了没(found)/合法吗(valid)/会生成哪几个阶段」。编辑完 yaml 先预览一下,确认它真能被解析,再依赖它。别让「静默回退」把你的配置错误也一起藏了。运行详情里也会挂一个来源徽章:这次运行用的是「仓库main· .pipewright.yml」还是「网页配置」,一眼可见。如果它显示的是「网页配置」而你以为该走 yaml,那就是回退发生了,去查 yaml。五、诚实说清楚:这套的边界自己搓的东西,边界得讲明白,免得你按错误的预期用:没有双向同步。画布改了,不会自动生成 yaml;yaml 改了,也不会回写画布。就是单向的:开关打开时,仓库 yaml → 驱动运行。想从画布「导出」成 yaml?现在没这功能,得手写(或让 AI 帮你写)。改 yaml 必须推代码才生效。它是「推代码触发运行时去读」,不是「存了 yaml 就热加载」。想让新 yaml 生效,得触发一次新运行。yaml 能表达的字段是画布的子集。画布上那些「变量与缓存」「触发配置」的高级 tab,yaml 目前不一定都覆盖。核心的阶段/任务/依赖/审批门/矩阵/条件都有,但别指望 100% 对齐画布。文件位置写死:仓库根目录、文件名.pipewright.yml。不支持放子目录或改名。来源是「记录」不是「强制」。徽章告诉你这次用了哪个源,但你不能在单次运行里说「这次强制走画布」——切换只有项目级开关这一个粒度。这些不是 bug,是「够用优先」的取舍。把它们讲清楚,你才知道什么时候该用画布、什么时候该上 yaml。六、那到底该选哪个?我的经验:画布适合——刚起步、流水线还在频繁试错、团队里有人不写 yaml、想快速点点看效果。yaml适合——流水线定型了、想进版本控制和 code review、需要「配置跟分支走」、想让「改流水线」这件事本身也走 PR 评审。而「同时支持、每项目一个开关」的意义在于:你不用一开始就站队。先用画布把流程跑通,定型之后把它固化成.pipewright.yml提交进仓库、打开开关,平滑迁移过去,画布配置留着当回退安全网。这大概是自托管场景下最不折腾的路径。七、这些我都做进了工具里上面这套——画布可视化编排、.pipewright.yml声明式定义、每项目开关、按分支读 yaml、出错静默回退、预览端点校验、运行详情的来源徽章——我把它连同前几篇讲的CI 构建、多机部署、Caddy 自动 HTTPS、Per-PR 预览环境、零停机部署回滚,一起塞进了我那个 Go 单二进制部署工具Pipewright:两种范式都给:网页画布拖拽编排,或仓库.pipewright.yml声明式定义,每项目一个开关切换;按运行分支读 yaml,feature 分支改流水线只影响自己,配置真正跟分支走;yaml 缺失/非法自动回退画布配置,运行永不中断;配一个预览端点先校验再依赖;运行详情挂来源徽章,这次用的是仓库 yaml 还是网页配置一目了然。开源 MIT,单二进制、无运行时依赖(前端embed.FS打进二进制,默认 SQLite),定位个人开发者 / 小团队自托管。仓库:https://github.com/huangchengsir/pipewright但就算你不用它,这篇的核心结论照样成立:画布 vs yaml 不是「谁更高级」,而是「什么阶段用什么」;真要两者兼得,关键是想清楚「一次运行只听一个源」,再用一个开关平滑地在两者之间迁移。欢迎来提 issue 拍砖。