一、前言

Hexo 自动部署不只是“构建后把 public 复制到服务器”。如果直接覆盖正式目录,上传中断、两个工作流并发或构建产物不完整,都可能让线上网站进入半更新状态。本篇从源码仓库开始,逐步建立依赖锁定、构建验证、临时上传、部署锁、备份激活和缓存排查。

本文中的服务器用户名、地址和目录都是示例。SSH 私钥必须保存到 GitHub Secrets,不要写进仓库、文章或日志。

二、交互演示:一次提交怎样安全到达服务器

GitHub Actions 安全部署流程

三、第一步:分清三个位置

GitHub 仓库
保存 Hexo 源码、配置、文章和 package-lock.json
↓ GitHub Actions
public/
工作流临时生成的静态文件
↓ SSH / rsync
服务器正式目录
Nginx 实际提供给访客的文件

不要在服务器正式目录中直接运行 Hexo,也不要把服务器目录当作源码仓库。

四、第二步:准备仓库构建命令

package.json 至少提供:

{
"scripts": {
"clean": "hexo clean",
"build": "hexo generate",
"verify": "node tools/verify-build.mjs"
}
}

提交 package-lock.json,并在本地验证:

npm ci
npm run clean
npm run build
npm run verify

npm ci 严格按照锁文件安装;依赖声明与锁文件不一致时直接失败,比自动修改依赖树更适合 CI。

五、第三步:建立构建验证脚本

只检查 hexo generate 退出码不够。可以在 tools/verify-build.mjs 检查:

import { access, readFile } from 'node:fs/promises'

const required = [
'public/index.html',
'public/archives/index.html',
'public/categories/index.html',
'public/tags/index.html'
]

for (const file of required) {
await access(file)
const html = await readFile(file, 'utf8')
if (!html.includes('<html') || !html.includes('</html>')) {
throw new Error(`Invalid HTML: ${file}`)
}
}

项目还可以继续验证本地 JS/CSS 引用、空白页面、关键自定义模块和构建日志中的 ERRORFATAL

六、第四步:创建 GitHub Actions 工作流

新建 .github/workflows/deploy.yml

name: Build and deploy Hexo

on:
push:
branches: [main]
workflow_dispatch:

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

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

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

concurrency 防止两个提交同时激活不同版本,但服务器端仍应有部署锁,避免手动发布与 Actions 冲突。

七、第五步:把 SSH 信息放进 Secrets

仓库 Settings → Secrets and variables → Actions 中添加:

DEPLOY_HOST       服务器地址
DEPLOY_PORT SSH 端口
DEPLOY_USER 受限部署用户
DEPLOY_KEY 私钥

服务器公钥验证建议使用 known_hosts,不要长期关闭主机密钥检查:

- name: Prepare SSH
shell: bash
run: |
install -m 700 -d ~/.ssh
printf '%s' "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ secrets.DEPLOY_PORT }}" \
"${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts

部署用户只授予目标目录和激活脚本所需权限,不要直接使用 root 私钥。

八、第六步:先上传 staging

生成唯一发布目录:

- name: Upload staging release
shell: bash
run: |
RELEASE="blog-${GITHUB_SHA}"
ssh -p "${{ secrets.DEPLOY_PORT }}" \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"mkdir -p /www/staging/$RELEASE"

rsync -az --delete \
-e "ssh -p ${{ secrets.DEPLOY_PORT }}" \
public/ \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/www/staging/$RELEASE/"

上传过程中,Nginx 继续提供旧版本,访客不会看到不完整文件。

九、第七步:在服务器验证 staging

激活前检查:

test -s "$STAGING/index.html"
test -s "$STAGING/archives/index.html"
test -d "$STAGING/css"
test -d "$STAGING/js"

也可以抽查关键自定义页面。任何检查失败都应退出,保留现有正式版本。

十、第八步:使用部署锁

服务器激活脚本开头:

exec 9>/var/lock/blog-deploy.lock
flock -n 9 || {
echo "Another deployment is running"
exit 1
}

即使 Actions 已配置并发控制,部署锁仍能覆盖手动执行、重跑任务和其他发布入口。

十一、第九步:备份当前版本

TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/www/backups/blog-$TIMESTAMP"

mkdir -p "$BACKUP"
rsync -a "$LIVE/" "$BACKUP/"

保留最近若干版本,定期删除更旧的备份,避免磁盘耗尽。删除逻辑要严格限制在 /www/backups/ 内。

十二、第十步:保留宝塔和证书保护文件

正式目录中可能有不属于 Hexo 的文件,例如:

.well-known/
.user.ini

激活前先保存,切换后再恢复:

PRESERVE="$(mktemp -d)"

test -d "$LIVE/.well-known" && cp -a "$LIVE/.well-known" "$PRESERVE/"
test -f "$LIVE/.user.ini" && cp -a "$LIVE/.user.ini" "$PRESERVE/"

rsync -a --delete "$STAGING/" "$LIVE/"

test -d "$PRESERVE/.well-known" && cp -a "$PRESERVE/.well-known" "$LIVE/"
test -f "$PRESERVE/.user.ini" && cp -a "$PRESERVE/.user.ini" "$LIVE/"

不要使用没有边界检查的通配符删除正式目录。

十三、第十一步:验证 Nginx 并准备回滚

if ! nginx -t; then
rsync -a --delete "$BACKUP/" "$LIVE/"
echo "Nginx validation failed; rolled back"
exit 1
fi

如果服务器上任意其他虚拟主机配置损坏,nginx -t 也可能失败。此时不要跳过校验,应找出具体 vhost 问题。

十四、第十二步:由 Actions 调用激活脚本

- name: Activate release
shell: bash
run: |
RELEASE="blog-${GITHUB_SHA}"
ssh -p "${{ secrets.DEPLOY_PORT }}" \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"/usr/local/bin/activate-blog-release '$RELEASE'"

复杂服务器操作集中在服务器脚本中,工作流只负责上传和调用,审查与回滚更清晰。

十五、第十三步:发布后检查线上页面

至少验证:

https://blog.example.com/
https://blog.example.com/archives/
https://blog.example.com/categories/
一篇最新文章
一个自定义 CSS/JS 资源

静态资源带反盗链时,直接访问可能返回假 404,应使用博客页面 Referer 测试。

十六、第十四步:代码更新但线上没变化怎样排查

按层检查:

  1. Actions 使用的 commit 是否正确;
  2. 构建日志中是否生成目标文件;
  3. staging 是否包含新文件;
  4. 激活脚本是否真正更新 LIVE;
  5. Nginx root 是否指向该目录;
  6. HTML 是否引用新的资源版本;
  7. CDN 和浏览器是否仍命中旧缓存。

不要只检查本地源码,也不要只检查服务器某个同名目录;先确认它是否真是 Nginx 当前 root。

十七、第十五步:处理静态资源缓存

HTML 可以使用较短缓存,带版本的 CSS/JS 使用长期缓存:

<script src="/js/home-banner-carousel.js?v=20260808-1" defer></script>

修改内容后更新版本号。对于内容哈希文件,文件名变化本身就是缓存失效信号。

十八、第十六步:完整发布验证

本地先运行:

npm ci
npm run clean
npm run build
npm run verify

推送后检查:

  1. 工作流所有步骤通过;
  2. staging 上传文件数量合理;
  3. 服务器部署锁正常获取;
  4. 正式目录保护文件仍存在;
  5. 最新备份能够找到;
  6. 线上首页与关键页面是新版本;
  7. 新 CSS/JS URL 返回正确内容;
  8. 回滚脚本在测试目录中演练通过。

十九、常见问题

1. npm ci 报锁文件不一致

在本地使用正确 Node 版本执行 npm install 更新锁文件,检查差异后提交。不要在工作流中改用 npm install 掩盖问题。

2. Actions 成功但服务器仍是旧页面

检查激活脚本和 Nginx root,常见原因是 staging 已更新但正式目录未切换,或者查看了另一个同名目录。

3. nginx -t 因其他站点失败

全局配置中任一虚拟主机错误都会导致校验失败。修复对应 vhost,不要删除当前博客的安全校验。

4. 图片直接访问 404,页面中却正常

这是反盗链 Referer 规则的可能表现。以首页 Referer 请求资源再判断,不要据此删除图片。

二十、维护入口与结语

  • 构建发布流程:.github/workflows/deploy.yml
  • 构建验证:tools/verify-build.mjs
  • 服务器激活与回滚:受限的服务器脚本
  • Secrets:GitHub 仓库设置
  • 缓存版本:站点配置与静态资源 URL

安全发布的核心是把“上传”和“激活”分开:只有完整、验证通过的新版本才有资格接管正式目录;任何阶段失败,都保留当前可用版本并提供清晰回滚点。