一句话总结
三十多行 YAML 就能把"push 一篇文章,站点自动更新"跑通.
真正耗时间的不是写 YAML,而是搞清楚 Job 跑在一个什么样的环境里,以及依赖版本和凭证在那个环境里处于什么状态.
手动部署的三条命令
这个博客是 Hexo 静态站,部署链路很短:本地生成 public/ 目录,再把它同步到服务器上 Nginx 的站点根目录. 手动做一遍就是三条命令:
|
命令不多,问题在于每写一篇文章都要重复一遍,而且它绑在某台特定机器上:
- 换电脑要重新配 SSH 密钥和 Node 环境
- 忘了
hexo generate就会把旧内容同步上去,而且不会有任何报错 - rsync 路径手滑打错,配上
--delete足以删掉服务器上不该删的目录
CI 要解决的就是这类问题:把"人得记着做的步骤"变成"仓库里写死的步骤",由事件自动触发,在一个每次都一样的环境里执行.
Workflow 的层级结构
GitHub Actions 的配置有四层,从外到内嵌套. 先建立这个骨架,后面每个字段都是往这个骨架里填东西:
|
| 层级 | 是什么 | 关键约束 |
|---|---|---|
| Event | 触发条件:push,提 PR,定时,手动点按钮 | 一个 workflow 可以监听多个事件 |
| Workflow | 一条完整流水线,对应 .github/workflows/ 下的一个 YAML 文件 |
文件名任意,目录名固定 |
| Job | 一组 Step,跑在一台独立的 Runner 上 | 多个 Job 默认并行,环境互相隔离 |
| Step | 一条 shell 命令,或一个复用的 Action | 同 Job 内顺序执行,前一步失败则后续中断 |
每个 Job 都是一台全新的空白机器
Job 开始时是干净的系统镜像,结束时整台虚拟机销毁. 装好的依赖,写入的文件,落盘的私钥,全都不会留到下一个 Job.
这一条解释了后面很多设计:为什么需要缓存,为什么 Job 之间传文件要走 artifacts,以及为什么把私钥直接写进 ~/.ssh/ 是可以接受的做法.
四层模型的通用部分在云原生分类的 GitHub Actions 实战一篇里讲过,这里不重复,直接落到一个真实文件上逐字段拆.
字段逐个拆
on:什么时候跑
on 决定触发条件,是整个文件里唯一和"时机"有关的部分:
|
部署类 workflow 通常只监听 push 到主干. 加上 pull_request 意味着别人提 PR 就会触发部署,这显然不合理——PR 阶段该跑的是测试,不是部署.
为什么部署 workflow 不该监听 pull_request
除了"未合并的代码不该上线"这个显而易见的理由,还有一层安全考虑:来自 fork 仓库的 pull_request 事件默认拿不到仓库 Secret. 部署步骤依赖 Secret,在 PR 触发下必然失败.
jobs 与 runs-on:在哪儿跑
|
runs-on 常用值是 ubuntu-latest,macos-latest,windows-latest. 需要访问内网或特定硬件时可以挂自托管 Runner,但要自己负责机器的安全和维护.
多个 Job 默认并行. 需要串行时用 needs 声明依赖:
|
steps:跑什么
Step 只有两种写法,uses 和 run:
|
uses 像调用现成的库函数,别人把能力封装好了,传参就能用.
run 像自己写一行代码,和在终端里敲的完全一样.
${{ ... }} 是表达式语法,用来引用上下文变量. 常用的几个上下文:secrets.X 取仓库密钥,github.ref 取当前分支,matrix.X 取矩阵变量,inputs.X 取手动触发时填的参数.
Action 的版本一定要锁
@v4 不是可选的装饰. 写 @main 意味着上游一更新,流水线行为就可能悄悄改变,甚至被注入恶意代码——这是真实存在的供应链攻击面. 对安全敏感的场景可以锁到具体的 commit SHA.
这个博客的完整 workflow
把上面的字段拼起来,就是仓库里 .github/workflows/deploy.yml 的全部内容:
|
这份文件里没有出现任何 pnpm 或 Node 的版本号. 两个版本都由仓库里的另外两个文件决定:package.json 的 packageManager 字段和 .nvmrc. 这么做的收益不在 CI 而在本地——corepack 和 nvm 读的是同样这两个文件,本地和 Runner 因此必然跑在同一套工具链上. 后面复盘的第一个故障,根源就是这两边的版本不一致.
有两处顺序不能调换.
pnpm 必须装在 setup-node 之前. setup-node 的 cache: "pnpm" 要靠执行 pnpm store path 来定位缓存目录,如果这时系统里还没有 pnpm,这一步会直接失败. 这是"声明式配置里藏着隐式依赖"的典型例子,YAML 的缩进结构完全看不出这层关系.
--frozen-lockfile 必须在 generate 之前,并且不能省. 它的作用是:lockfile 和 package.json 不一致时直接报错,而不是自作主张更新 lockfile. CI 的价值就在于确定性,依赖版本必须完全由提交进仓库的 lockfile 决定.
--delete 为什么在这里是安全的
rsync --delete 会删掉目标端多余的文件,源目录一旦为空就等于清空站点,看起来很危险.
但同一个 Job 内的 Step 是失败即中断的:hexo generate 挂了,public/ 没生成,rsync 那一步根本不会执行. 让危险操作依赖前序步骤的成功,是 Step 顺序执行这个特性的正当用法.
私钥是怎么进到 Runner 里的
部署那一步的四行命令,每行都在解决 SSH 的一个具体要求:
|
私钥的传递路径是 仓库 Secret → Step 的环境变量 → Runner 上的文件. 用 env 中转而不是在 run 里直接插值,是有讲究的:直接写 echo "${{ secrets.DEPLOY_SSH_KEY }}",私钥内容会被拼进 shell 命令行,私钥里的特殊字符可能破坏命令语法,命令本身也更容易出现在进程列表或调试输出里. 走环境变量则只有 shell 自己能读到.
Secret 在日志里会被自动替换成 ***,但这只是兜底:它按字面匹配,一旦密钥被 base64 编码或分行输出,打码就失效了.
私钥离开 Secret 就等于泄露
Secret 的保护范围只在 GitHub 后台和 Runner 内存里. 私钥内容一旦以任何形式出现在别的地方——截图,聊天记录,粘贴板,CI 日志——就应当直接视为已泄露,立刻走轮换流程:生成新密钥对,更新服务器 authorized_keys,替换 Secret,再从 authorized_keys 删掉旧公钥.
判断标准不是"泄露出去的人可不可信",而是"这个私钥有没有离开过它该在的地方".
踩坑复盘
配置写完不等于跑通. 下面两个故障有一个共同特征:本地完全正常,CI 稳定失败.
故障一:pnpm install 直接 exit 1
日志末尾是这样:
|
依赖全都装完了,最后一步却报错退出. 报错说的是"忽略了 hexo-util 的构建脚本". pnpm 从 10 开始默认不执行依赖的 postinstall,这是防供应链攻击的措施,要放行得显式配置白名单. 而仓库里的 .npmrc 明明已经配了:
|
本地 pnpm install 一切正常,CI 里这行配置像不存在一样. 关键线索藏在日志开头两行:
|
store/v11 和 supply-chain 检查都是 pnpm 11 的特征,而本地装的是 10.33. workflow 里写的是 version: latest,于是 CI 装到了当时最新的 11.20.0. 一个大版本的差距,同时踩中了三个变更:
| 变更 | pnpm 10 | pnpm 11 |
|---|---|---|
.npmrc 读取范围 |
读所有 pnpm 配置 | 只读 auth 和 registry,其余一律不读 |
| 构建白名单字段名 | onlyBuiltDependencies |
改名 allowBuilds,值变成 { 包名: true } 的映射 |
| 忽略构建脚本的后果 | 打印警告框,exit 0 | 报 ERR_PNPM_IGNORED_BUILDS,exit 1 |
三条叠加的结果:白名单配置被静默忽略,而忽略构建的后果从警告升级成硬错误. 本地不报错,是因为 pnpm 10 两条都不成立.
顺带确认了这个 postinstall 不能跳过:hexo-util 的构建脚本会生成 highlight_alias.json,这个文件不在发布的 tarball 里,由安装时按实际的 highlight.js 版本现场生成. 跳过它,hexo 运行时会找不到模块. 所以只能放行,不能屏蔽.
止血的改法是新建 pnpm-workspace.yaml,把两个字段名并列写上:
|
两个版本各读自己认识的那个键,互相不认识的键被忽略且不报警,于是本地的 10 和 CI 的 11 都能跑. 同时把 .npmrc 删掉——在 pnpm 11 眼里那行配置已经没有意义,留着只会误导下一个读代码的人.
但这只是止血,真正的病根是本地和 CI 跑着两个大版本的 pnpm,而这件事在故障发生前没有任何人知道. 兼容两个版本的配置能让流水线变绿,却把"版本不一致"这个状态固化了下来,下一次大版本变更还会再撞一次.
根治的做法是消灭这个差异,让版本只有一个来源:
|
|
packageManager 是 corepack 认的字段,本地敲 pnpm 时 corepack 会自动切到 11.20.0;pnpm/action-setup 在省略 version 输入时读的也是这个字段. .nvmrc 同理,本地被 nvm 读取,CI 里由 setup-node 的 node-version-file 读取. 两边从此不可能不一致.
版本统一之后,pnpm-workspace.yaml 里那份兼容写法就可以收掉,只留 pnpm 11 认的那一个键:
|
CI 里的 latest 是定时炸弹
version: latest 的问题不是"装到了新版本",而是升级时机完全不受控:上游发布的那一刻,一次和代码毫无关系的 push 就会突然失败,而 git 历史里找不到任何对应的改动.
工具链版本应该和依赖版本一样被钉住,升级作为一次独立的,可 review 的提交. pnpm 12 的 RC 已经发布,继续写 latest 迟早会被同一类问题再咬一次.
升级本地 pnpm 时会遇到的一个提示
pnpm 11 的虚拟目录布局和 10 不同,在已有 node_modules 的目录里首次运行会要求确认清理. 非交互环境下会直接报 ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
手动 rm -rf node_modules 后重装即可. CI 里不会遇到,因为 Runner 上本来就没有 node_modules.
故障二:rsync 被服务器拒绝
依赖问题解决后,卡在最后一步:
|
这段日志的信息量比看起来大:
Permission denied (publickey,password)里的括号列出了服务器接受的认证方式,不是失败原因. 真正的信息是公钥认证没通过- 两次
Permission denied, please try again.是密码认证的重试提示. 公钥失败后 ssh 退回去试密码,CI 里没有人能输入密码,于是空跑两次 - 没有出现
Load key: error in libcrypto之类的报错,说明私钥文件本身格式正确,能被解析
所以问题不在 Secret,而在服务器那边不认这把钥匙. 定位手法是指纹比对,ssh-keygen -lf 对私钥和公钥都能算出同一个指纹:
|
指纹一比就清楚了:服务器上授权的是另一把旧密钥 github-actions-note-mine,Secret 里那把 github-actions-deploy 从来没被授权过. 密钥对本身没问题,漏的是"把公钥交给服务器"这一步.
修复就是追加公钥,注意两个细节:
|
追加完之后要只用这把钥匙验证一次,否则本地 ~/.ssh/config 里的其他密钥会掩盖问题:
|
IdentitiesOnly=yes 禁止 ssh 试其他密钥,BatchMode=yes 禁止一切交互提示. 这两个选项后来也加进了 workflow 的 rsync 命令里:
|
不加也能跑通,加了是为了让失败的样子更明确. 原本那两行莫名其妙的 Permission denied, please try again.,就是 ssh 悄悄降级去试密码的产物,读日志时很容易被带偏. 配上 BatchMode 后,公钥不通会立刻报错说清原因.
排错方法论:在本地复刻 CI 环境
上面两个故障都是"本地正常,CI 失败",这类问题几乎都是环境差异,而 CI 环境的三个特征是可以在本地复刻的:指定版本的运行时,干净的依赖目录,一模一样的命令.
|
这么做的收益很直接:一轮验证只要几秒,而 push 一次要等一分多钟;不会在 git 历史里留下一串 fix ci 提交;临时目录跑坏了随时删,不影响正在用的 node_modules.
先复现,再修
这次的两个故障,都是在 /tmp 的副本里先稳定复现出和 CI 一模一样的报错,才动手改配置的.
靠着这个副本,改完能立刻验证到位:pnpm 11 下 postinstall 确实执行了,highlight_alias.json 确实生成了,hexo generate 确实产出 255 个文件. 推上去之前就已经知道会绿.
修完之后别忘了验证部署真的落地了,而不是只看 Actions 页面变绿:
|
rsync -a 会保留源端的修改时间,所以目标文件的 mtime 就是 CI 生成它的时刻. 这个时间对上了,才算真的部署成功.
快速回顾
- 四层结构:Event 触发 → Workflow(一个 YAML)→ Job(全新虚拟机,默认并行)→ Step(顺序执行,失败即中断)
- Step 两种写法:
uses复用 Action 并用with传参,run直接执行 shell - 隐式依赖:
setup-node的cache: "pnpm"要求 pnpm 已经装好,YAML 结构看不出这层顺序约束 - Secret 传递:走
env中转而非直接插值,避免私钥拼进命令行;日志打码只是兜底,不能依赖 - 工具链版本单一来源:
packageManager和.nvmrc同时被本地和 CI 读取,从根上消除"本地能跑"的版本差异;latest会让升级时机脱离控制 - 读 SSH 报错:
(publickey,password)是服务器接受的方式而非失败原因;能解析私钥说明问题在授权侧 - 指纹比对:
ssh-keygen -lf对私钥,公钥,authorized_keys都能算指纹,一比就知道有没有授权 - 本地复刻 CI:临时目录 + 指定版本运行时,几秒一轮,比反复 push 试错快两个数量级
Job 结束后 Runner 上的私钥文件会留下吗
不会. 托管 Runner 用完即销毁,整台虚拟机连同 ~/.ssh/id_ed25519 一起消失,下一次运行是全新的机器. 这也是"把私钥直接写进文件"在 CI 里可以接受的原因——它的生命周期只有这一次运行的几十秒.
ssh-keyscan 每次都重新抓 host key,不是等于关掉了中间人防护吗
是的. 这一步只是为了让非交互的 ssh 不因为 host key 未知而拒绝连接,并没有校验抓到的 key 是否可信.
更严谨的做法是把服务器的 host key 也存成 Secret,用固定值写入 known_hosts,这样中间人替换 host key 时连接会直接失败. 对个人博客来说风险可以接受,对生产环境不应该这么省.
为什么不用 hexo deploy 而是自己写 rsync
hexo-deployer-rsync 之类的插件能做同样的事,但要多装一个插件,多维护一份 _config.yml 里的部署配置,出问题时排查链路也更长.
部署逻辑只有四行 shell 的情况下,直接写 rsync 反而更透明:每一行在做什么,报错对应哪一行,都是一目了然的.
能不能在本地直接跑整个 workflow
act 这类工具可以用 Docker 在本地执行 workflow,适合验证 YAML 语法和步骤编排. 但它对 Secret,网络环境,托管 Runner 预装软件的模拟都不完整,遇到本文这两类"环境差异"故障时,复刻单个命令往往比复刻整个 workflow 更快也更准.
动手练习
- 搭最小部署流水线:给一个静态站仓库写
deploy.yml,实现 push 到 main 后自动构建并同步到服务器,全程不手动执行任何命令. - 验证失败即中断:故意让构建命令返回非零退出码,确认后面的 rsync 被跳过而不是照常执行,亲眼看到
--delete没有清空站点. - 复刻一次 CI 故障:把 workflow 里的工具链版本改成
latest,在本地用npx <工具>@latest复现出和 CI 一致的报错,再钉回固定版本. - 做一次指纹比对:用
ssh-keygen -lf分别算出本地私钥,本地公钥和服务器authorized_keys的指纹,确认哪几把钥匙真正被授权. - 加固 host key 校验:把服务器 host key 存进 Secret,用它替换
ssh-keyscan那一行,再故意写一个错误的 key,确认连接会失败. - 走一遍密钥轮换:生成新密钥对,更新服务器
authorized_keys和仓库 Secret,删掉旧公钥,确认部署仍然成功——这是私钥泄露后必须能熟练执行的流程.