用 OneDev 的构建功能实现 push 后自动部署
本文最后更新于 2026年10月16日 下午
前两篇分别是 服务器自部署Hexo网站实现自动化构建(服务器端构建)和 hexo的deploy部署命令部署法(本地构建 + hexo deploy 推送产物)。前一篇的痛点是服务器上得养一套 node 工具链,后一篇的痛点是发布还得手动敲一条命令。这篇是第三个阶段:把构建交给 OneDev 这个 CI,push 之后自己跑完并部署,本地一条 git push 就够了,也不用在服务器上装 node。
顺带说明一下为什么是 OneDev 而不是 GitHub Actions / Gitea Actions:我的服务器在国内,访问 GitHub 的 runner 经常抽风;而 OneDev 本身就跑在自己的内网机器上,既是 git 服务器又是 CI,构建不出内网。
一、先看现在有什么
承接上一套:产物接收端已经有了
上一篇文章里那套「裸仓库 + post-update 钩子 + rsync」是现成的,站点在另一台 Web 服务器上(下面统一写成 <Web服务器>):
1 | |
这一套一个字都不用改,新方案只是把「谁来构建、谁来做那次 push」从我的笔记本换成了 OneDev。
OneDev 是什么、在哪
- 它跑在我自己的内网机器上(Docker 里跑
1dev/server),对外通过内网穿透暴露成一个 git 地址; - 博客源码的 remote 已经是它,git 走 SSH 端口,而不是 GitHub 那种托管服务。
关键的一点:这个容器是挂着 docker socket 启动的:
1 | |
所以它有能力起构建容器。而这台机器宿主机上并没有装 node/pnpm(正是上一套方案特意避免的事),构建就放在容器里做,正好。
二、OneDev 的 CI 是怎么被定义的
一句话:CI 定义写在仓库里的 .onedev-buildspec.yml 文件里,跟代码一起做版本管理。
它的工作方式是这样的:
- 推送到达 → OneDev 在服务端解析这次推送的 buildspec 文件;
- 解析时顺便做校验(这一步后面踩了大坑);
- 校验通过后,按 buildspec 里的
triggers决定是否创建构建、按steps执行。
解析失败不会主动告诉我,只在服务端日志里留一行 DefaultJobService Error checking job triggers,表现就是推了代码但什么也没发生。这是这次最值得记的教训,下面单独说。
顺带一个概念区分:
- Job(作业):一段构建定义,比如「构建并发布站点」;
- Build(构建):作业的一次执行,带编号,比如
#4; - Trigger(触发器):什么事件启动这个作业,我们用的是
BranchUpdateTrigger(分支有更新)。
三、这次的 buildspec
文件就放在仓库根目录,叫 .onedev-buildspec.yml。完整内容如下(下面单独解释每个部分):
1 | |
三个步骤在干什么
| 步骤 | 作用 |
|---|---|
CheckoutStep |
OneDev 用内部凭据把源码 clone 到工作目录,不需要我配任何密钥 |
构建(CommandStep) |
在 node:22-bookworm 容器里装依赖、生成 public/ |
发布(CommandStep) |
在同一个容器里,把 public/ 提交成一个新的 git 仓库,push -f 到 Web 服务器 |
几个选择理由:
- 为什么是
node:22:pnpm12 要求 Node ≥ 20.12,Hexo 8 要求 ≥ 18,取 22 LTS 最省心; - 为什么
corepack enable:package.json里写了packageManager: pnpm@12.10.1,corepack 会据此准备对应版本的 pnpm,和本地的版本一致,避免「本地能构建、CI 上不行」; - 为什么分两步而不是一步:构建步骤里加了
test -f public/index.html这种断言,能把「构建失败」和「发布失败」分开,出错时一眼知道是哪一环; - 为什么
--frozen-lockfile:锁文件和package.json不一致时直接失败,而不是静默改依赖——CI 上最怕的就是它偷偷给你换版本; - 为什么
push -f:那个仓库只存产物,历史没有意义(跟gh-pages一个套路),这一点在上一篇文章里已经论证过; runAs: '0:0':容器里以 root 跑,方便写/root/.ssh、也避免 bind mount 的属主问题。
触发器
1 | |
只监听 master 的更新。这样在别的分支上折腾文章不会误发布,和原来 _config.yml 里 deploy 的分支保持一致。
四、部署这一环:一个专用密钥
发布步骤需要把产物推到 Web 服务器,所以得给 OneDev 一个能推那个仓库的凭据。做法是专门的部署密钥,不复用我自己的私钥:
1 | |
然后在 buildspec 里用 @secret:site-deploy-key@ 引用它,OneDev 会把它注入成环境变量 DEPLOY_KEY,脚本再写成文件给 ssh 用:
1 | |
两个细节:
IdentitiesOnly=yes:只认指定的这把钥匙,不去翻别的 agent key,避免「明明配了却认证失败」;StrictHostKeyChecking=accept-new:容器里没有对方的known_hosts,首次连接自动接受。对 CI 容器这种一次性环境是合适的取舍(没写成no,还是保留了「主机密钥变了就报警」的能力)。
为什么用户名可以省略
ssh://deployer%40<Web服务器IP> 里的用户名其实不影响认证(SSH 只认密钥),写上去是为了可读——一眼能看出是谁的身份在推。
五、完整链路
1 | |
对比上一套,变化只在最上面两段:原来是我在本地敲 hexo g -d,现在是 OneDev 在容器里做同样的事。Web 服务器和 Nginx 那一段完全没动。
六、踩过的坑(这次一天踩完的)
坑 1:命令正文里的 @ 会被当成变量插值(唯一的大坑)
这个坑最阴,因为它不报在页面上。现象是:git push 成功了、OneDev 也收到了提交、但构建记录里什么都没有。
翻服务端日志才看到:
1 | |
也就是说 OneDev 把 @ 当作它自己的插值语法分隔符:
@...@成对出现 = 引用变量,比如@build_number@、@secret:xxx@;- 想让
@当字面量,必须写两个连写(@@); - 落单的
@直接判定 buildspec 非法,校验发生在推送时的同步阶段,于是作业根本不会被创建。
我一开始很自然地写了 git push ssh://用户名@<Web服务器IP>... 和 git config user.email 用户名@域名,两个都踩上了。
解法:命令正文里凡是要 @ 的地方统一写 %40。
1 | |
%40 是 URL 里的 @ 编码,git 解析 ssh 地址时会正常处理(实测可用),而 OneDev 的插值器对 % 不敏感,于是两边都满意。
顺带记一个附带发现:@build_number@ 这种变量在 commands 里也能用,但如果把它写进字符串里的引号内侧(比如提交信息里),我干脆改成用 envVars 注入环境变量 BUILD_NUMBER 再引用 $BUILD_NUMBER——语义更清楚,也少一次插值解析。
坑 2:git 提交身份
发布步骤里要先在 public/ 下 git init 再提交,如果不设身份,git 会直接拒绝:
1 | |
容器里没有全局 git 配置,也没有可以推导的用户信息,所以必须显式设 user.name 和 user.email。(邮箱同样受上面 %40 规则约束。)
坑 3:执行器
一开始担心要自己配「Job Executor」,结果日志里写得很清楚:
1 | |
OneDev 16 内置了 serverdocker 这个执行器插件(/app/lib/io.onedev.server-plugin-executor-serverdocker-*.jar),会自动发现并使用挂在容器里的 docker socket。所以 jobExecutor 留空就行,只有当真的报「没有可用执行器」时才需要去 Administration → Job Executors 手动建一个 Server Docker Executor(字段全默认即可)。
坑 4:一次推送一件事,别连推
调试时我在几十秒内连续推了三个提交,结果只有第一个提交有构建记录,后面两个连记录都没生成(服务端日志里能看到 Error checking job triggers 在挨个提交上刷)。所以调试时推一个、等构建出结果、再推下一个,不然会误判成「触发器不工作」。
七、怎么验证真的部署了
三个层面交叉验证,这是我现在的固定动作:
1 | |
第 2 条里那个 (OneDev build #4) 是特意加进提交信息的:产物仓库每次都是 push -f,只有一个提交,带上构建编号之后就能和 OneDev 的构建记录对上号,排查时省事。
八、四种方案的对比
| 维度 | 服务器端构建 | 本地 hexo g -d |
Gitea Actions | OneDev CI |
|---|---|---|---|---|
| 触发 | push 源码 | 手动命令 | push 源码 | push 源码 |
| 谁构建 | 服务器(宿主机 node) | 我的电脑 | runner 容器 | OneDev 容器 |
| 服务器要装 node | 要 | 不要 | 不要 | 不要 |
| 报错在哪看 | 服务器日志 | 本地终端 | Actions 页面 | Builds 页面 + 提交状态 |
| CI 定义在哪 | 钩子脚本 | 无 | 仓库里的 workflow | 仓库里的 buildspec |
| 发布是否自动 | 是 | 否 | 是 | 是 |
| 现状 | 保留作兜底 | 保留作兜底 | 已废弃删除 | 在用 |
Gitea 那一列是真实经历:当时也写了 .gitea/workflows/deploy.yaml,但 runner 一直卡在「解析不出域名」的重试里,工作流压根没跑起来,最后连 workflow 文件一起删了。
九、遗留与后续
hexo d还能用,而且和 OneDev 推的是同一个仓库。_config.yml里的deploy.repo指向的正是那个产物仓库。两条路都推同一个仓库,本身不冲突(都是产物),但同时用容易搞混「这次上线是谁推的」。彻底统一的话,可以把deploy段和hexo-deployer-git一起去掉。- Web 服务器上还有一套服务器端构建(裸仓库的
post-receive),只要不往那个裸仓库推源码就不会触发,留着当手工兜底。 pnpm install每次都跑(约 30 秒)。要省时间可以用!CacheStep缓存 pnpm store,但目前这点耗时能接受,先不折腾。- 构建耗时:一次完整构建大约 50 秒(含容器启动、装依赖、生成、推送)。对个人博客足够。
十、小结
- OneDev 不只是 git 服务器,它自带 CI;构建定义写在仓库里的
.onedev-buildspec.yml,跟着代码版本管理,比在网页上点配置可追溯。 - 这套方案把「构建」从我的电脑和服务器宿主机上搬进了 CI 容器:本地和服务器都不需要 node 环境,push 即发布。
- 接收端一行没改,直接复用上一篇文章里那套「裸仓库 +
post-update+rsync」——换的只是「谁来做那次 push」。 - 最大的坑是 OneDev 的
@插值语法:命令正文里落单的@会让 buildspec 校验失败,而且失败在推送的同步阶段,页面上什么都看不到。用%40绕开。 - 调试时一次只推一个提交,否则连续的推送可能不生成构建记录,容易被误判成触发器坏了。