BLOG

CI CD 自动部署

2026/04/30 12 min read BLOG 编程规范 CI CD 自动部署

我给你用最直白、最接地气的方式讲清楚,你马上就能懂👇 # 1. npm run selftest:pre-commit 通过 是什么意思? 这句话翻译成人话: ### 你本地电脑上,「提交前自检脚本」跑完全部通过了 ✅ 它具体做了这些事: - 运行代码格式检查(lint) - 运行代码规范检查 - 运行单元测试/基础自测 - 检查有没有语法错误、格式错误、明显bug 通过 = 你现在的代码可以安全 commit(提交) 不通过 = 不让你提交,必须先修复错误 --- # 2. CI 可以在本地跑吗? ### 完全可以!而且你现在就在本地跑 CI! 你看到的: - local checks = 本地版 CI 检查 - CI checks = 服务器版 CI 检查 它们99% 是同一套脚本,只是运行环境不一样: - 本地:你电脑 - 服务器:GitHub / GitLab 云端机器 ### 所以: 你本地跑 npm run selftest:pre-commit = 你在本地提前模拟跑了一遍 CI 好处: - 不用推到云端就知道能不能过 - 提前修复,避免云端 CI 变红 - 速度快很多 --- # 3. 你现在这套结构是什么?(超级重要) 你现在是双层保护: ### 第一层(本地) git 提交前 → 自动跑 selftest → 不通过不让提交 ### 第二层(云端) git push 后 → CI 自动再跑一遍 same checks → 不通过不让合并代码 --- # 4. 超简总结(你只需要记这 3 句) 1. npm run selftest:pre-commit 通过 = 本地提交前自检全过,可以提交代码。 2. CI 可以在本地跑 = 你现在跑的就是本地版 CI,和云端逻辑一样。 3. 你现在是双层防护 = 本地拦一次 + CI 拦一次,保证代码不会乱提交。 --- 如果你愿意,我可以用一张图给你画出你现在的完整提交流程,让你一眼看懂整个门禁机制!

CI/CD 自动部署:从 GitHub-hosted 账单门禁切到 170 自托管 runner 的一套正确做法

先说结论

这次最关键的认知不是“把 workflow 写出来了”,而是:

自动部署是否真的可用
!=
仓库里有一个 GitHub Actions YAML

真正可用的自动部署,至少要同时满足四件事:

  1. push 后确实会触发 job
  2. job 确实能在真实执行机上跑起来
  3. 部署脚本走的是 canonical 路径,不是临时拼装
  4. 部署后有公网与内部双验证,而不是只看 workflow 绿了

这次 Token Pool 的落地,最终收敛成了下面这条真实链路:

开发机改代码

push 到 GitHub main

GitHub Actions workflow: .github/workflows/deploy-runtime.yml

170 自托管 runner: token-pool-prod-170

actions/checkout

deploy/deploy-runtime-local.sh

git archive HEAD -> tar -xf -> npm ci --omit=dev

systemctl restart token-pool-gateway-prod.service

内部健康检查 + 公网入口验证

一、这次真正踩到的坑,不是代码,是执行地点

一开始表面上看,是“已经有自动部署 workflow 了,但怎么就是不自动上线”。

后来真正查出来,问题根本不在业务代码,而在执行地点:

workflow 跑在 GitHub-hosted runner

GitHub 账号被账单 / spending limit 门禁卡住

job 连启动都启动不了

看起来像“自动部署坏了”

这个阶段最容易犯的错是继续改 YAML 细节,或者继续怀疑 SSH。

但只要 job 都没真的跑起来,后面所有 SSH、scp、docker、systemctl 都是伪命题。

所以第一条工程原则是:

先确认 job 到底跑在哪台机器上

如果执行器本身起不来,再漂亮的 workflow 也是假的。


二、为什么 GitHub-hosted + SSH 容易变成“看似自动,实则脆弱”

很多项目的第一版自动部署都会写成这样:

GitHub-hosted runner

SSH 到服务器

scp 文件

远程执行 restart

这条链不是不能用,但它天然有几个问题:

1. 执行器和运行机分离

真正跑服务的是服务器,但真正执行部署的是 GitHub 的云主机。

这意味着:

  • 中间多一层网络和 SSH 依赖
  • 需要 repo secret 里长期保留部署私钥
  • 任何 GitHub-hosted 额度、账单、并发限制,都会直接影响 deploy

2. YAML 容易堆成脚本坟场

如果把大量 SSH 逻辑直接塞进 workflow:

  • 可读性会越来越差
  • 别的项目很难复用
  • 出问题时很难判断到底是 workflow、SSH、还是部署逻辑坏了

3. 容易误把“push 成功”当成“deploy 成功”

这类项目里最常见的错觉就是:

我已经 push 了
=
线上应该已经更新

实际上必须拆开看:

push

是否触发 workflow

workflow 是否真的开始执行

部署脚本是否成功

服务是否成功重启

公网是否验证通过

任何一步没过,都不能叫“已部署”。


三、为什么这次要改成 170 自托管 runner

这次的正确解法,不是继续打补丁,而是把 deploy job 的执行地点直接迁到真实运行机:

GitHub 只负责下发任务
170 自己执行 checkout / deploy / restart / verify

这样做的直接收益:

1. 避开 GitHub-hosted 账单门禁

job 不再依赖 GitHub 的云主机额度,而是跑在你自己的服务器上。

2. 自动部署链更短

从:

GitHub runner -> SSH -> 170

变成:

170 本机 runner -> 170 本机 runtime

中间少了一整层“远程跳板”复杂度。

3. 默认不再需要 repo 级 SSH 私钥

因为 workflow 已经在 170 本机上跑,所以不再需要把 TOKEN_POOL_DEPLOY_SSH_KEY 这种部署私钥长期塞在 GitHub secrets 里。

这件事很重要,因为:

不需要的高权限 secret
=
应该删除

不是“留着也没事”。


四、正确结构:runner 负责执行,deploy 脚本负责部署

这次不是只改了一个 workflow,而是把职责拆开了。

1. workflow 只负责调度

Deploy runtime to 170 现在做的事很简单:

  • 命中路径过滤后触发
  • 绑定到 self-hosted, token-pool, production, cn-170
  • actions/checkout
  • deploy/deploy-runtime-local.sh

也就是说:

workflow = 调度层

2. deploy 脚本负责 canonical 发布动作

真正的发布动作收口在仓内脚本:

deploy/deploy-runtime-local.sh

它负责:

  • git archive HEAD
  • 解包到 /srv/token-pool-gateway-prod
  • npm ci --omit=dev
  • systemctl restart token-pool-gateway-prod.service
  • 健康检查和公网验证

也就是说:

deploy 脚本 = 发布层

3. runner 安装模板负责基础设施复用

为了让别的项目也能照搬,这次又额外把 runner 安装动作抽成模板:

  • deploy/setup-self-hosted-runner.sh
  • deploy/setup-self-hosted-runner.env.example

也就是说:

runner 安装模板 = 基础设施复用层

这三层分开之后,整个系统会稳定很多。


五、旧 SSH 发布链应该怎么处理

一个很容易犯的错是:

新链路上线了

旧链路还留着

大家继续混着用

这会制造长期认知污染。

正确做法不是“假装旧路径不存在”,而是明确降级:

当前 Token Pool 的做法

  • deploy/deploy-runtime-local.sh:当前 production 默认发布路径
  • deploy/deploy-runtime-170.ps1:历史 / 应急 SSH 发布路径

而且不只是 README 写一句“历史”就算了,还要在脚本自身里直接提示:

emergency SSH deploy path in use
prefer deploy-runtime-local.sh for normal production deploys

这样就算有人直接打开旧脚本,也会立刻看到它不是默认路径。


六、secret 管理的正确原则

这次有一个很值钱的收尾动作:

把 TOKEN_POOL_DEPLOY_SSH_KEY 从 GitHub repo secrets 删除

为什么要删?

因为它已经不是自动部署必需项了。

如果继续留着,会带来两个问题:

1. 误导认知

后来的维护者会以为:

自动部署还依赖 SSH 私钥

实际上已经不依赖。

2. 扩大不必要的敏感面

一个 secret 只要存在,就意味着:

  • 它可能被误用
  • 它需要被轮换
  • 它增加了配置复杂度

所以工程上更好的规则是:

不再需要的 secret,应该删除,而不是保留

当前这套结构下,保留的只应该是:

  • TOKEN_POOL_DEPLOY_VERIFY_TOKEN

它用于部署后的受保护接口验证,仍然有实际价值。


七、怎么判断“自动部署真的正常了”

不要只看:

  • 本地没报错
  • YAML 看起来很漂亮
  • GitHub 页面有个绿色勾

真正应该看的是这几层证据:

1. 最新 workflow run 成功

而且要确认是跑在目标 self-hosted runner 上。

2. 服务真重启成功

例如:

token-pool-gateway-prod.service = active

3. 内部健康检查通过

例如:

  • http://127.0.0.1:3301/health

4. 公网入口验证通过

例如:

  • https://pool-console.tengokukk.com/api/health
  • https://pool-console.tengokukk.com/console-next/
  • https://key.tengokukk.com/openai/v1/responses

只有这些证据一起成立,才能说:

自动部署正常

而不是“看起来好像正常”。


八、给以后项目复用的最小模板

如果别的项目也有一台长期在线的生产机,推荐直接复用这套思路:

项目 repo
├── .github/workflows/deploy-runtime.yml
├── deploy/deploy-runtime-local.sh
├── deploy/setup-self-hosted-runner.sh
└── deploy/setup-self-hosted-runner.env.example

最小原则只有五条:

  1. 有长期在线主机时,优先自托管 runner,不优先 GitHub-hosted + SSH
  2. workflow 只做调度,不在 YAML 里塞满部署细节
  3. 部署动作收口到 repo 内 canonical 脚本
  4. deploy 后必须做内部 + 公网双验证
  5. 不再需要的 deploy secret 必须删除

九、一句话总结

这次最值钱的不是“把自动部署打通了”,而是把下面这套工程认知真正落地了:

执行器在哪里
比 YAML 写得多漂亮更重要

以及:

push != deployed
workflow success != runtime success
不再需要的 secret != 留着备用

如果把这三句话记住,后面很多 CI/CD 假成功、部署误判、权限污染的问题,都会少很多。