前言

博客迁移到 GitHub Actions 后,源码、构建结果和线上目录有了清晰边界。本篇不讨论按钮操作,重点解释一条可靠发布链路为什么要有锁、暂存目录、验证、备份和受保护文件。

一、先分清三个位置

GitHub main 分支
保存 Hexo 源码、文章、配置和锁文件

GitHub Actions runner
安装依赖,生成 public/,执行验证

服务器 /www/wwwroot/blog.gyyra.cn
只保存最终静态 HTML、CSS、JS 和图片

线上目录不是源码仓库。直接修改服务器里的 HTML,下次部署会被构建结果覆盖;把 public/ 提交到 Git,也会同时维护源码和产物两份内容。

因此 .gitignore 忽略:

node_modules/
public/
.deploy_git/
db.json

二、什么操作会触发发布

工作流监听 main 分支推送,也允许手工触发:

on:
push:
branches: [main]
workflow_dispatch:

并发配置为:

concurrency:
group: blog-production
cancel-in-progress: false

同一时间只允许一条生产发布链路运行。cancel-in-progress: false 表示新提交不会在旧版本复制到一半时强行终止它,而是排队等待,避免线上目录处于半更新状态。

三、为什么使用 npm ci 而不是 npm install

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci

npm ci 严格按照 package-lock.json 安装。如果 package.json 和锁文件不一致会直接失败,不会悄悄选一个新版本。

对静态站来说,可重复构建很重要:同一个提交今天和下周构建,应该得到兼容的依赖组合。

四、构建为什么分三步

- name: Build site
run: |
npm run clean
npm run build
npm run verify

1. clean

删除上次生成的 public/,防止已经从源码删除的旧文件残留。

2. build

执行 hexo generate,把 Markdown、模板和数据转换为静态站。

3. verify

构建成功只代表 Hexo 没有抛出致命错误,不代表页面一定正确。验证脚本还会检查:

  • index.html404.html 是否存在;
  • 所有站内 hrefsrc 是否指向真实文件;
  • 首页是否泄漏相册 JSON;
  • 统计页数据和 ECharts 是否存在;
  • ECharts 是否错误地加载到所有页面;
  • 非文章页面头图是否按预期移除;
  • 页脚、归档链接等关键定制是否仍然存在。

验证失败时,流程在上传前停止,错误版本不会到达服务器。

五、为什么先上传到 staging

Actions 没有直接把文件同步到网站根目录,而是传到:

/home/blogdeploy/gyyra-blog-staging/

上传命令使用:

rsync -az --delete \
public/ user@server:/home/user/gyyra-blog-staging/
  • -a 保留目录结构和常规属性;
  • -z 传输时压缩;
  • --delete 删除暂存区中已不属于新版本的文件。

如果网络中途断开,受影响的只是暂存区,线上网站仍保持上一个完整版本。

六、服务器激活脚本做了什么

上传完成后,Actions 通过 SSH 执行一个受控脚本:

sudo /usr/local/sbin/deploy-gyyra-blog

脚本开头启用严格模式:

set -Eeuo pipefail

含义是命令失败、未定义变量或管道中间失败时立即退出,避免错误被忽略后继续覆盖线上目录。

七、部署锁怎样防止两个版本互相覆盖

exec 9>/var/lock/deploy-gyyra-blog.lock

flock -n 9 || {
echo 'Another blog deployment is already running.' >&2
exit 1
}

脚本占用一个文件锁。另一个发布同时到达时无法获得锁,会直接失败,而不是两份 rsync 交叉写入同一个目录。

GitHub Actions 的并发控制属于云端第一道保护,服务器文件锁是第二道保护。

八、为什么激活前还要检查文件

test -f "$staging/index.html"
test -f "$staging/404.html"
test -d "$target"

这是最小烟雾检查:暂存区必须像一个完整网站,目标目录也必须已经存在。

它不能替代完整验证,但可以阻止路径写错、上传空目录等明显事故。

九、备份怎样产生

激活新版本前,脚本创建带时间戳的备份目录:

stamp=$(date +%Y%m%d-%H%M%S)
install -d -m 0755 "$backups/$stamp"

rsync -a \
--exclude='.well-known/' \
--exclude='.user.ini' \
"$target/" "$backups/$stamp/"

如果新版本上线后发现视觉问题,可以从最近备份恢复静态站,不必等待重新构建。

十、为什么 .well-known 和 .user.ini 不能删除

网站目录里有些文件不由 Hexo 生成:

  • .well-known/ 可能用于 HTTPS 证书验证;
  • .user.ini 是宝塔面板生成的目录保护文件。

生产同步使用 --delete 时,如果不排除它们,rsync 会认为这些文件“不在新版本中”并删除。

正确激活命令:

rsync -a --delete \
--exclude='.well-known/' \
--exclude='.user.ini' \
--chown=www:www \
"$staging/" "$target/"

--chown=www:www 让 Nginx 使用的账号保持正确所有权。

十一、SSH 密钥为什么放在 Secrets

工作流通过以下 Secrets 获取连接信息:

SERVER_SSH_KEY
SERVER_KNOWN_HOSTS
SERVER_HOST
SERVER_PORT
SERVER_USER

私钥不能写进仓库。known_hosts 也不能简单关闭验证,否则攻击者可能伪装服务器接收部署文件。

工作流给私钥和 known_hosts 设置 600 权限,SSH 才会接受这些文件。

十二、代码更新了但线上没变化,先查哪一层

可以按四层顺序排查:

1. GitHub:提交是否已经进入 main
2. Actions:build、verify、upload、activate 是否全部成功
3. 服务器:目标文件时间和内容是否已经变化
4. 浏览器:是否仍在使用缓存资源

不要一上来反复修改代码。先确定变化在哪一层停止,才能避免把部署问题误当成程序问题。

十三、静态资源缓存怎样处理

HTML 通常更新较快,但 CSS 和 JS 可能被浏览器或反向代理缓存很久。修改资源后可以更新查询版本号:

<script src="/js/home-banner-carousel.js?v=20260715-2"></script>
<link rel="stylesheet" href="/css/custom.css?v=20260716-2">

浏览器把不同查询字符串视为不同缓存键,会重新请求文件。

调试时在开发者工具 Network 面板确认:

  • 请求地址是不是新版本号;
  • 状态是 200 还是来自 memory/disk cache;
  • Response 内容是不是新代码;
  • 页面 HTML 是否仍引用旧地址。

十四、为什么直接打开图片可能显示 404

线上资源启用了防盗链时,服务器可能检查 Referer。直接把图片地址粘贴进新标签页时没有来自博客首页的来源信息,于是返回 404;在博客页面中加载却是正常的。

验证这类资源时应:

  1. 从实际博客页面打开开发者工具观察请求;
  2. 或在请求中携带 Referer: https://blog.gyyra.cn/
  3. 同时检查 Nginx 日志,区分“文件不存在”和“规则拒绝”。

这也是为什么线上排查不能只看一个直接 URL 的状态码。

十五、日常发布流程

修改源码

npm run clean
npm run build
npm run verify

提交并推送 main

观察 GitHub Actions

打开线上页面并检查实际资源请求

十六、维护入口

  • 自动化步骤:.github/workflows/deploy.yml
  • 服务器激活脚本:ops/deploy-gyyra-blog
  • 构建验证:tools/verify-build.mjs
  • 依赖版本:package.jsonpackage-lock.json
  • 发布说明:README.md

结语

可靠部署的重点不是“自动上传”,而是上传前验证、上传时隔离、激活前备份、激活时加锁,并明确哪些服务器文件必须被保护。