以前更新一篇博客,写完并不算结束。我还得在本地跑 npm run build,确认生成了页面,再用 scp 把文件传到服务器。流程不复杂,但只要少做一步,就会出现“仓库里已经是新版,线上还是旧版”的情况。
前几天给主站配置完自动部署,我顺手把博客也接上了。现在往博客仓库的 master 分支推送,GitHub Actions 会在一台临时机器上重新安装依赖、生成 Hexo 静态文件,检查关键页面,再通过 SSH 同步到服务器。文章不是要把 CI/CD 名词讲得多玄乎,而是记录这条链怎么搭、每一步防的是什么问题。下面的例子以 Hexo 和自己的 Linux 服务器为例,换成 Astro、Vite 等静态站时,主要改构建命令和产物目录。
先把手动流程写明白
自动化之前,我的博客手动部署大致是:
1 | 写文章 → git push → 本地 npm run build → 上传 index/ → 打开线上页面检查 |
GitHub Actions 并不知道我的博客怎么构建。它只会按仓库里的工作流文件执行命令,所以先要确认三件事:
- 仓库里有
package-lock.json,能用npm ci按锁文件安装依赖; package.json的build脚本确实调用了hexo generate;_config.yml把public_dir设成index,实际要上传的是index/,不是 Hexo 默认的public/。
我一开始就在这里撞了墙:旧锁文件有 236 个依赖地址指向以前用过的私有 npm 镜像,本机偶尔能装,GitHub 的机器却未必能访问。后来把锁文件中的下载地址换为 npm 官方源,保留版本与完整性校验值,再用一次全新安装验证。本机已有的 node_modules 能跑,不代表一台空机器也能跑;部署前执行一次 npm ci,比等 Actions 报错省心。
工作流放在哪里
在仓库里新建 .github/workflows/deploy.yml。下面是我博客工作流的可复用版本,master、域名、服务器用户和主机公钥需要按你的环境修改:
1 | name: Build and deploy blog |
上面看着长,其实只分成四段:什么时候跑、如何构建、怎样上传、上线后怎么确认。GitHub 的工作流语法文档可以查每个字段的完整定义,这里挑容易忽略的地方说。
push.branches: [master] 表示只有推送到博客的 master 才自动发布。很多新仓库用 main,照抄时要看自己的默认分支。workflow_dispatch 则在 Actions 页面留一个手动运行入口,排查问题时很有用。permissions: contents: read 只给工作流读取仓库所需的权限。
actions/checkout 把代码取到运行器;setup-node 指定 Node 版本,并用锁文件关联 npm 缓存。真正的安装仍由 npm ci 完成,缓存只是减少重复下载,setup-node 的说明也把这两件事分开了。构建完先检查 index/index.html 和页面数量,避免“命令退出码是 0,却没有可用网站”时继续上传。你的项目如果输出到 dist/,这里和同步命令都要一起改。
concurrency 把同一站点的部署串起来,避免两个运行同时写服务器。cancel-in-progress: false 表示不打断正在执行的部署;如果短时间连续推送多次,等待中的运行仍可能由后来的运行取代,具体排队规则以 GitHub 的并发文档为准。
SSH 密钥只给博客目录的写入权
最容易图省事的做法,是把自己平时登录服务器的私钥塞进 Actions,再让它以 root 上传。我之前手动发布确实这么干过;但自动化会长期在每次推送时运行,这把钥匙给的权限太大。
这次我给博客生成一对独立的 SSH 密钥。在 Windows PowerShell 中可以这样做:
1 | New-Item -ItemType Directory -Force -Path "$HOME\.ssh" | Out-Null |
命令会生成 blog_actions(私钥)和 blog_actions.pub(公钥)。无人值守的工作流不能在连接时输入口令,因此生成这把专用密钥时,口令提示留空,并通过服务器端的强制命令限制它的用途。私钥放在仓库的 DEPLOY_SSH_KEY Secret 中,公钥放在服务器部署账号的 authorized_keys 中。GitHub 页面路径是 Settings → Secrets and variables → Actions → New repository secret;官方文档也列出了创建与引用方式。私钥不要提交到仓库,更不要写进 YAML。
在服务器上,我给公钥前面加了强制命令,形式如下:
1 | restrict,command="/usr/bin/rrsync -wo /srv/www/blog/index" ssh-ed25519 AAAA... blog-actions |
这里的 /srv/www/blog/index 是示例路径,必须换成你服务器真实的博客静态文件目录。rrsync 会把这个密钥的 rsync 操作限制在该目录内;restrict 禁用端口转发等额外能力。部署账号也应只拥有这个目录需要的文件权限。不要把主站和博客共用一把能随意登录的私钥。工作流目标写成 blog-deploy@YOUR_HOST:./,由服务器端的强制命令决定最终写入位置。
还有一把“钥匙”经常被漏掉:known_hosts 里的服务器主机公钥。它用来确认连接的是预期的服务器。先从可信途径核对公钥指纹,再把对应公钥写入工作流;不要为了让连接成功就关掉 StrictHostKeyChecking。这段是公开信息,可以出现在仓库里,和必须保密的部署私钥不是一回事。
第一次运行,按什么顺序准备
这里有个容易忽略的时间顺序:推送工作流文件本身,就会触发第一次运行。所以我先让服务器部署账号能够写入博客目录、确认 rrsync 已安装,再把受限公钥写进该账号的 ~/.ssh/authorized_keys;接着在 GitHub 仓库创建 DEPLOY_SSH_KEY,最后才提交并推送 deploy.yml。如果反过来做,首次运行通常会停在密钥为空或 SSH 连接失败,虽然可以补完后重新运行,但排错会多绕一圈。
推送后进入仓库的 Actions 页面,不要只看最上方那个绿勾。逐步看 Install and build、Check build output、Sync site to server 和 Verify public page;失败时展开第一个失败的步骤,从报错前几行找原因。密钥内容不要贴进日志,也不要为了试连接临时改成 StrictHostKeyChecking=no。如果手动点击 Run workflow,它会按所选分支再跑一次,适合修好服务器权限后复验;但已经推送到正式分支的工作流,也会按触发条件自动运行。
同步为什么用 rsync
手动 scp -r 可以上传,但不擅长处理“文章或图片已从构建产物删掉,服务器还留着旧文件”的情况。rsync 能比较两边并同步差异,所以这里用它发布静态目录。
命令中的 --delete 会删除目标目录里源目录没有的文件,一定先确认目标是专用站点目录。我的博客站点地图由服务器另一处的定时任务生成,不在这次同步目录里;如果你的站点目录有手工维护的文件,要先把它们纳入构建产物、排除规则或独立目录,再开 --delete。--delay-updates 则尽量在传输结束后再替换更新文件。
首次运行时,我的安装、构建和页面检查全绿,同步却报了 rsync error code 23。往上翻日志,真正的原因是:
1 | failed to set times on ".../index/2024": Operation not permitted |
那些旧目录以前由 root 上传,即使部署账号能写文件,也不能随意修改目录时间戳。给同步命令加上 --omit-dir-times 后,第二次运行就通过了。这不是“错误码 23 一律加这个参数”:先看前面的具体错误;如果是 Permission denied、路径不对或密钥验证失败,修法完全不同。已有目录的属主和权限最好也单独整理清楚。
发布后还要看什么
最后的 curl 只确认线上入口能返回成功状态。它不是完整的页面验收:返回 200 也可能是旧缓存,正文里的图片也可能 404。我的做法是再打开一篇最新文章,检查正文和插图;像这次博客以前遇到的 SVG 点击无法预览,就属于 HTTP 检查发现不了的问题。
这条流水线跑通后,我更新博客的动作变成了“写文章、检查本地预览、推送”。构建和上传交给工作流,失败会留在 Actions 日志里,可以从安装 → 构建 → 产物检查 → SSH → 同步 → 线上检查这条顺序定位。它没有让发布变得完全不用管,只是把重复步骤写成了所有人都能看见、也能重新执行的流程。
如果你准备照着搭,建议先在一个只有测试内容的仓库走通一次,并在启用 --delete 前确认服务器目标目录。等构建产物、密钥限制和线上路径都核对过,再让推送触发正式发布。对我来说,这比每次上传前重新回忆“上次那条命令怎么写”踏实得多。
记录于 2026-09-28。图是流程示意,不是 GitHub Actions 页面的实拍;示例中的域名、账号、公钥和服务器路径需要替换为自己的配置。