用 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
2
3
/srv/blog/deploy.git      静态产物裸仓库
└─ hooks/post-update checkout 到暂存目录 → rsync 到站点目录
/srv/blog/www Nginx 直接读的站点目录

这一套一个字都不用改,新方案只是把「谁来构建、谁来做那次 push」从我的笔记本换成了 OneDev。

OneDev 是什么、在哪

  • 它跑在我自己的内网机器上(Docker 里跑 1dev/server),对外通过内网穿透暴露成一个 git 地址;
  • 博客源码的 remote 已经是它,git 走 SSH 端口,而不是 GitHub 那种托管服务。

关键的一点:这个容器是挂着 docker socket 启动的:

1
2
3
4
5
docker run ... \
-v /var/run/docker.sock:/var/run/docker.sock \
-v <数据目录>:/opt/onedev \
-p 6610:6610 -p 6611:6611 \
1dev/server

所以它有能力起构建容器。而这台机器宿主机上并没有装 node/pnpm(正是上一套方案特意避免的事),构建就放在容器里做,正好。

二、OneDev 的 CI 是怎么被定义的

一句话:CI 定义写在仓库里的 .onedev-buildspec.yml 文件里,跟代码一起做版本管理。

它的工作方式是这样的:

  1. 推送到达 → OneDev 在服务端解析这次推送的 buildspec 文件;
  2. 解析时顺便做校验(这一步后面踩了大坑);
  3. 校验通过后,按 buildspec 里的 triggers 决定是否创建构建、按 steps 执行。

解析失败不会主动告诉我,只在服务端日志里留一行 DefaultJobService Error checking job triggers,表现就是推了代码但什么也没发生。这是这次最值得记的教训,下面单独说。

顺带一个概念区分:

  • Job(作业):一段构建定义,比如「构建并发布站点」;
  • Build(构建):作业的一次执行,带编号,比如 #4;
  • Trigger(触发器):什么事件启动这个作业,我们用的是 BranchUpdateTrigger(分支有更新)。

三、这次的 buildspec

文件就放在仓库根目录,叫 .onedev-buildspec.yml。完整内容如下(下面单独解释每个部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
version: 53
jobs:
- name: Build and Deploy Site
# jobExecutor 留空 = 由 OneDev 自动选择执行器
# jobExecutor: server-docker
steps:
- !CheckoutStep
name: 检出源码
cloneCredential: !DefaultCredential {}
withLfs: false
withSubmodules: false
condition: SUCCESSFUL

- !CommandStep
name: 构建站点
runInContainer: true
image: docker.io/library/node:22-bookworm
interpreter: !PosixInterpreter
shell: sh
commands: |
set -e
corepack enable
export CI=1
pnpm config set store-dir /root/.pnpm-store
pnpm install --frozen-lockfile
pnpm exec hexo clean
pnpm exec hexo generate
test -f public/index.html
echo "构建产物: $(find public -type f | wc -l) 个文件, $(du -sh public | cut -f1)"
useTTY: false
runAs: '0:0'
condition: SUCCESSFUL

- !CommandStep
name: 发布静态产物
runInContainer: true
image: docker.io/library/node:22-bookworm
interpreter: !PosixInterpreter
shell: sh
commands: |
set -e
# 命令正文里的单个 at 符号会被 OneDev 校验拒绝,需要 at 的地方一律用 %40
mkdir -p /root/.ssh && chmod 700 /root/.ssh
printf '%s\n' "$DEPLOY_KEY" > /root/.ssh/deploy_key
chmod 600 /root/.ssh/deploy_key
export GIT_SSH_COMMAND="ssh -i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
cd public
rm -rf .git
git init -q -b master .
git config user.name "OneDev"
git config user.email "onedev%40example.com"
git add -A
git commit -q -m "Site updated: $(date '+%Y-%m-%d %H:%M:%S') (OneDev build #$BUILD_NUMBER)"
git push -f ssh://deployer%40<Web服务器IP>/srv/blog/deploy.git master
envVars:
- name: DEPLOY_KEY
value: '@secret:site-deploy-key@'
- name: BUILD_NUMBER
value: '@build_number@'
useTTY: false
runAs: '0:0'
condition: SUCCESSFUL
triggers:
- !BranchUpdateTrigger
branches: master
userMatch: anyone
retryCondition: never
maxRetries: 3
retryDelay: 30
timeout: 1800

三个步骤在干什么

步骤 作用
CheckoutStep OneDev 用内部凭据把源码 clone 到工作目录,不需要我配任何密钥
构建(CommandStep) 在 node:22-bookworm 容器里装依赖、生成 public/
发布(CommandStep) 在同一个容器里,把 public/ 提交成一个新的 git 仓库,push -f 到 Web 服务器

几个选择理由:

  • 为什么是 node:22:pnpm 12 要求 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
2
3
4
triggers:
- !BranchUpdateTrigger
branches: master
userMatch: anyone

只监听 master 的更新。这样在别的分支上折腾文章不会误发布,和原来 _config.yml 里 deploy 的分支保持一致。

四、部署这一环:一个专用密钥

发布步骤需要把产物推到 Web 服务器,所以得给 OneDev 一个能推那个仓库的凭据。做法是专门的部署密钥,不复用我自己的私钥:

1
2
3
4
5
6
7
8
9
10
11
# 1. 生成一对专用密钥(无口令,CI 里用)
ssh-keygen -t ed25519 -N '' -C "onedev-site-deploy" -f site_deploy

# 2. 公钥放进 Web 服务器上部署账号的 authorized_keys
# (产物仓库的属主就是那个账号)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
echo 'ssh-ed25519 AAAA... onedev-site-deploy' >> ~/.ssh/authorized_keys

# 3. 私钥存进 OneDev 的 Job Secret
# 项目 → Settings → Job Secrets → 名称 site-deploy-key

然后在 buildspec 里用 @secret:site-deploy-key@ 引用它,OneDev 会把它注入成环境变量 DEPLOY_KEY,脚本再写成文件给 ssh 用:

1
2
3
printf '%s\n' "$DEPLOY_KEY" > /root/.ssh/deploy_key
chmod 600 /root/.ssh/deploy_key
export GIT_SSH_COMMAND="ssh -i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"

两个细节:

  • IdentitiesOnly=yes:只认指定的这把钥匙,不去翻别的 agent key,避免「明明配了却认证失败」;
  • StrictHostKeyChecking=accept-new:容器里没有对方的 known_hosts,首次连接自动接受。对 CI 容器这种一次性环境是合适的取舍(没写成 no,还是保留了「主机密钥变了就报警」的能力)。

为什么用户名可以省略

ssh://deployer%40<Web服务器IP> 里的用户名其实不影响认证(SSH 只认密钥),写上去是为了可读——一眼能看出是谁的身份在推。

五、完整链路

1
2
3
4
5
6
7
8
9
10
11
本地写文章
└─ git push(remote 就是 OneDev)
└─► OneDev:BranchUpdateTrigger 命中 master
├─[1] 检出源码
├─[2] node:22 容器:corepack → pnpm install → hexo generate
└─[3] 同一容器:public/ → git init/commit → push -f
└─► <Web服务器>:/srv/blog/deploy.git
└─ hooks/post-update
├─ git checkout -f → .deploy-stage
└─ rsync -a --delete → /srv/blog/www
└─ Nginx 直接读

对比上一套,变化只在最上面两段:原来是我在本地敲 hexo g -d,现在是 OneDev 在容器里做同样的事。Web 服务器和 Nginx 那一段完全没动。

六、踩过的坑(这次一天踩完的)

坑 1:命令正文里的 @ 会被当成变量插值(唯一的大坑)

这个坑最阴,因为它不报在页面上。现象是:git push 成功了、OneDev 也收到了提交、但构建记录里什么都没有。

翻服务端日志才看到:

1
2
3
4
5
WARN io.onedev.server.job.DefaultJobService Error checking job triggers
jakarta.validation.ValidationException: Error validating build spec
(... location: jobs[0].steps[2].interpreter.commands,
message: Last appearance of @ is a surprise to me.
Either use @...@ to reference a variable, or use @@ for literal @)

也就是说 OneDev 把 @ 当作它自己的插值语法分隔符:

  • @...@ 成对出现 = 引用变量,比如 @build_number@、@secret:xxx@;
  • 想让 @ 当字面量,必须写两个连写(@@);
  • 落单的 @ 直接判定 buildspec 非法,校验发生在推送时的同步阶段,于是作业根本不会被创建。

我一开始很自然地写了 git push ssh://用户名@<Web服务器IP>... 和 git config user.email 用户名@域名,两个都踩上了。

解法:命令正文里凡是要 @ 的地方统一写 %40。

1
2
git config user.email "onedev%40example.com"
git push -f ssh://deployer%40<Web服务器IP>/srv/blog/deploy.git master

%40 是 URL 里的 @ 编码,git 解析 ssh 地址时会正常处理(实测可用),而 OneDev 的插值器对 % 不敏感,于是两边都满意。

顺带记一个附带发现:@build_number@ 这种变量在 commands 里也能用,但如果把它写进字符串里的引号内侧(比如提交信息里),我干脆改成用 envVars 注入环境变量 BUILD_NUMBER 再引用 $BUILD_NUMBER——语义更清楚,也少一次插值解析。

坑 2:git 提交身份

发布步骤里要先在 public/ 下 git init 再提交,如果不设身份,git 会直接拒绝:

1
2
fatal: unable to auto-detect email address (got 'root@<容器ID>.(none)')
Command exited with code 128

容器里没有全局 git 配置,也没有可以推导的用户信息,所以必须显式设 user.name 和 user.email。(邮箱同样受上面 %40 规则约束。)

坑 3:执行器

一开始担心要自己配「Job Executor」,结果日志里写得很清楚:

1
2
No job executor defined, auto-discovering...
Discovered server docker executor

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
3
4
5
6
7
8
9
10
11
# 1. OneDev 页面:项目 → Builds,看最新一条的状态和日志
# 或者从提交页看提交上的状态标记(红/绿)

# 2. Web 服务器上产物的提交记录(提交信息里带着构建编号,一眼对得上)
git --git-dir=/srv/blog/deploy.git log --oneline -3
# 3f60ba2 Site updated: <时间> (OneDev build #4)

# 3. 站点目录确实被写过、文件数和仓库一致
stat -c '%y %n' /srv/blog/www/index.html
git --git-dir=/srv/blog/deploy.git ls-tree -r --name-only master | wc -l
find /srv/blog/www -type f | wc -l

第 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 文件一起删了。

九、遗留与后续

  1. hexo d 还能用,而且和 OneDev 推的是同一个仓库。_config.yml 里的 deploy.repo 指向的正是那个产物仓库。两条路都推同一个仓库,本身不冲突(都是产物),但同时用容易搞混「这次上线是谁推的」。彻底统一的话,可以把 deploy 段和 hexo-deployer-git 一起去掉。
  2. Web 服务器上还有一套服务器端构建(裸仓库的 post-receive),只要不往那个裸仓库推源码就不会触发,留着当手工兜底。
  3. pnpm install 每次都跑(约 30 秒)。要省时间可以用 !CacheStep 缓存 pnpm store,但目前这点耗时能接受,先不折腾。
  4. 构建耗时:一次完整构建大约 50 秒(含容器启动、装依赖、生成、推送)。对个人博客足够。

十、小结

  • OneDev 不只是 git 服务器,它自带 CI;构建定义写在仓库里的 .onedev-buildspec.yml,跟着代码版本管理,比在网页上点配置可追溯。
  • 这套方案把「构建」从我的电脑和服务器宿主机上搬进了 CI 容器:本地和服务器都不需要 node 环境,push 即发布。
  • 接收端一行没改,直接复用上一篇文章里那套「裸仓库 + post-update + rsync」——换的只是「谁来做那次 push」。
  • 最大的坑是 OneDev 的 @ 插值语法:命令正文里落单的 @ 会让 buildspec 校验失败,而且失败在推送的同步阶段,页面上什么都看不到。用 %40 绕开。
  • 调试时一次只推一个提交,否则连续的推送可能不生成构建记录,容易被误判成触发器坏了。

用 OneDev 的构建功能实现 push 后自动部署
https://www.xiebingyuan.cn/2026/10/3c85072a2109/
作者
bingyuan
发布于
2026年10月16日
更新于
2026年10月16日
许可协议