这个网站是一个基于 Hexo 7.3 和 Butterfly 主题的静态博客。文章、配置和主题定制保存在 Git 仓库中,提交到 main 分支后,由 GitHub Actions 自动完成依赖安装、站点生成、图片压缩和服务器部署。

本文记录当前项目实际使用的自动化流程。文中的域名、服务器地址、登录用户、部署目录、验证文件名和私钥均不展示真实值,而是通过 GitHub Secrets 或 Variables 注入。

一、整体流程

一次部署依次经过以下阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
提交并推送到 main

GitHub Actions 检出完整 Git 历史

恢复 source 目录中文件的最后修改时间

安装依赖并执行 Hexo 构建

使用 Sharp 增量压缩 public 中的图片

通过 SSH 清理服务器上的旧站点文件

通过 SCP 上传新的静态文件并检查 index.html

项目中与这套流程有关的文件如下:

1
2
3
4
5
6
7
8
9
project_web/
├─ .github/workflows/sync.yml # GitHub Actions 工作流
├─ run.sh # 根据 Git 历史恢复源文件时间
└─ blog/
├─ package.json # Hexo、Sharp 和构建命令
├─ package-lock.json # npm 锁文件
├─ tools/optimize-images.js # 图片增量优化脚本
├─ source/ # 文章和静态资源
└─ public/ # 构建产物,不纳入版本控制

二、为什么要自动化

静态网站的部署看起来只是“生成文件再上传”,但手工操作很容易出现几个问题:

  • 本地 Node.js 或依赖版本不同,导致构建结果不一致;
  • 忘记上传某个文件,线上内容处于新旧混合状态;
  • 每次都重新压缩全部图片,部署时间越来越长;
  • Hexo 读取文件系统时间后,文章更新时间可能被检出操作改写;
  • 私钥、服务器地址或站点路径被直接写进仓库。

把这些步骤固定在 CI/CD(持续集成与持续部署)工作流中,可以让每次发布使用相同的环境和命令,也能把敏感配置留在仓库之外。

三、准备服务器和 GitHub Environment

1. 创建专用部署用户

服务器应使用权限受限的部署用户,不要让流水线直接以 root 身份登录。部署用户只需要拥有站点目录的读写权限,以及通过 SSH 登录的权限。

在可信设备上为流水线生成一对独立的密钥:

1
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./github_actions_deploy

把公钥 github_actions_deploy.pub 添加到部署用户的 ~/.ssh/authorized_keys,把私钥 github_actions_deploy 的完整内容保存到 GitHub Environment Secret。私钥不能提交到仓库,也不要在日志中输出。

2. 配置 Environment

当前工作流使用名为 secrets 的 GitHub Environment。建议在该 Environment 中配置以下内容:

类型 名称 作用 示例值
Secret SERVER_HOST 服务器主机名或 IP <SERVER_HOST>
Secret SERVER_USER SSH 部署用户 <DEPLOY_USER>
Secret SSH_PRIVATE_KEY 部署专用私钥全文 <PRIVATE_KEY_CONTENT>
Variable SERVER_DEPLOY_PATH 服务器站点绝对路径 <DEPLOY_DIRECTORY>

表格中的尖括号内容只是占位符,不能原样用于部署。GITHUB_TOKEN 不需要手工创建,它由 GitHub Actions 为每次运行临时生成。本项目只用它在构建阶段读取公开的 GitHub 贡献数据,因此工作流将仓库权限限制为 contents: read

如果 Environment 配置了审批规则,生产部署会在执行前等待批准,这也能避免一次误推送立即覆盖线上站点。

四、固定构建环境

项目在 blog/package.json 中声明 Node.js 24.18.1、npm 11.16.0,工作流同时固定 Node.js 版本,并通过 package-lock.json 恢复依赖:

1
2
3
cd blog
npm ci --legacy-peer-deps --prefer-offline --no-audit --no-fund
npm run build

这里使用 npm ci 而不是 npm install,原因是它严格按照锁文件安装,更适合可复现构建。--legacy-peer-deps 用于兼容当前 Hexo 插件的依赖关系,--no-audit --no-fund 可以减少与构建无关的网络请求和日志。

常用脚本如下:

1
2
3
4
5
6
7
8
{
"scripts": {
"build": "hexo generate",
"clean": "hexo clean",
"images:optimize": "node tools/optimize-images.js",
"server": "hexo server"
}
}

本地只想检查内容时,可以执行:

1
2
3
cd blog
npm run build
npm run server

npm run clean 会删除 public/ 和 Hexo 数据库缓存,不应把它当作普通检查命令随意执行。CI 中的工作区是一次性的,因此可以在构建前清理这些可再生成内容。

五、恢复文章的文件时间

Git 记录提交历史,但不会保存文件系统的修改时间。Actions 检出仓库后,许多文件会得到接近当前时间的时间戳,可能影响主题显示的文章更新时间。

本项目的 run.sh 会查询每个 blog/source 文件最近一次提交的 Unix 时间戳,再使用 GNU touch 写回文件时间:

1
2
3
4
5
6
#!/usr/bin/bash
export TZ='Asia/Shanghai'

git ls-files --directory ./blog/source | while read path; do
touch -d "$(git log -1 --format='@%ct' "$path")" "$path"
done

因为脚本需要查询完整提交历史,actions/checkout 必须设置 fetch-depth: '0'。如果使用默认的浅克隆,较早文章的最后修改提交可能不在本地,恢复结果就不可靠。

六、使用 Sharp 增量优化图片

Hexo 生成完成后,tools/optimize-images.js 会递归扫描 blog/public/ 中的 .jpg.jpeg.png 文件。当前处理策略为:

  • 图片最长边限制在 1920 × 1080 范围内,并禁止放大小图;
  • JPEG 使用质量 80 和 MozJPEG 编码;
  • PNG 使用压缩级别 9
  • 如果压缩后的文件反而更大,则保留原文件;
  • 以“图片内容、扩展名和压缩配置”共同计算 SHA-256 缓存键;
  • 相同内容只处理一次,之后直接复用缓存结果;
  • 默认并发数不超过 2,降低 CI 中的瞬时内存压力。

缓存目录是 blog/.cache/sharp-images/v1。工作流先恢复旧缓存,只在出现新缓存内容时保存新版本,避免每次部署都上传完全相同的缓存。

本地修改图片后,可以先演练:

1
2
cd blog
IMAGE_DRY_RUN=true npm run images:optimize

演练模式只输出扫描数量、缓存命中数和预计压缩体积,不改写图片。Windows PowerShell 中可以这样设置一次性环境变量:

1
2
3
$env:IMAGE_DRY_RUN='true'
npm run images:optimize
Remove-Item Env:IMAGE_DRY_RUN

GIF 和 SVG 不在当前 Sharp 脚本的处理范围内。工作流仍保留旧 Gulp 图片流程作为紧急回退,但默认关闭,也不会把 Gulp 作为项目直接依赖安装。

七、完整的脱敏工作流

下面的示例保留了本项目的关键步骤,同时将所有环境相关信息改成 Secrets 或 Variables。真实站点的域名、服务器地址、用户、目录、验证文件名和密钥都不应出现在仓库中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
name: Build and deploy Hexo site

permissions:
contents: read

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest
environment: secrets
env:
USE_LEGACY_IMAGE_PIPELINE: 'false'

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: '0'
path: repo

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '24.18.1'
cache: npm
cache-dependency-path: repo/blog/package-lock.json

- name: Install dependencies
working-directory: repo/blog
run: npm ci --legacy-peer-deps --prefer-offline --no-audit --no-fund

- name: Build site
working-directory: repo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global core.quotepath false
chmod +x run.sh
./run.sh
cd blog
npm run clean
npm run build

- name: Restore Sharp image cache
if: env.USE_LEGACY_IMAGE_PIPELINE != 'true'
uses: actions/cache/restore@v6
with:
path: repo/blog/.cache/sharp-images/v1
key: sharp-images-v1-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
sharp-images-v1-${{ runner.os }}-

- name: Optimize images with Sharp
if: env.USE_LEGACY_IMAGE_PIPELINE != 'true'
id: sharp-images
working-directory: repo/blog
env:
IMAGE_OPTIMIZE_CONCURRENCY: '2'
run: npm run images:optimize

- name: Save Sharp image cache
if: env.USE_LEGACY_IMAGE_PIPELINE != 'true' && steps.sharp-images.outputs.cache_changed == 'true'
uses: actions/cache/save@v6
with:
path: repo/blog/.cache/sharp-images/v1
key: sharp-images-v1-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}

- name: Remove old site files
uses: appleboy/ssh-action@v1.0.3
env:
DEPLOY_PATH: ${{ vars.SERVER_DEPLOY_PATH }}
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: DEPLOY_PATH
script: |
set -eu
if [ -z "$DEPLOY_PATH" ] || [ "$DEPLOY_PATH" = "/" ]; then
echo "拒绝清理空路径或根目录"
exit 1
fi
test -d "$DEPLOY_PATH"
find "$DEPLOY_PATH" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +

- name: Upload site files
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: 'repo/blog/public/*'
target: ${{ vars.SERVER_DEPLOY_PATH }}
strip_components: 3

- name: Verify deployment
uses: appleboy/ssh-action@v1.0.3
env:
DEPLOY_PATH: ${{ vars.SERVER_DEPLOY_PATH }}
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: DEPLOY_PATH
script: test -s "$DEPLOY_PATH/index.html"

strip_components: 3 会去掉 repo/blog/public 这三级目录,只把 public/ 内的内容放进站点目录。缺少这个参数时,服务器上可能出现多余的 repo/blog/public/ 嵌套目录。

当前部署方式会先清理旧文件,再上传新文件,优点是不会残留已经从源码删除的页面;缺点是上传期间可能有短暂空窗。如果网站对连续可用性要求较高,可以进一步改成“上传到版本目录 → 校验 → 原子切换软链接 → 保留上一版本”的发布方式。

站点验证文件、robots.txt 等额外文件也应在构建阶段复制到 public/,但不要在公开文章或工作流中写入真实验证码文件名。更简单的做法是把不含敏感信息的固定静态文件放在 Hexo 的 source/ 下,让构建过程自动复制。

八、部署后的检查

工作流成功不等于网站一定正常。至少还需要检查:

  1. Actions 中安装、构建、压图、清理、上传和验证步骤均成功;
  2. 首页及一篇新文章可以访问,CSS、JavaScript 和图片没有出现 404
  3. 浏览器控制台没有资源路径或脚本错误;
  4. 服务器上的 index.html 非空,文件属主和 Web 服务读取权限正确;
  5. 图片优化日志中的输入、输出体积合理,没有意外放大文件;
  6. 修改旧文章后,页面显示的更新时间与 Git 历史一致。

如果部署失败,可以按阶段定位:

现象 优先检查
npm ci 失败 Node.js 版本、锁文件是否同步、对等依赖参数
文章时间异常 fetch-depth 是否为 0run.sh 是否有执行权限
图片步骤失败 public/ 是否已生成、Sharp 是否正确安装、CI 内存是否充足
SSH 连接失败 Environment 是否正确、主机与用户是否匹配、公钥是否已授权
上传成功但页面 404 SERVER_DEPLOY_PATHstrip_components 和 Web 根目录配置
新页面存在但旧页面也残留 清理步骤是否成功执行、部署用户是否有目录写权限

九、安全要点

  • 工作流只保存 Secret 的名称,不保存任何真实值;
  • 为部署单独创建 SSH 密钥,不复用个人日常登录密钥;
  • 使用权限受限的部署用户,并把写权限限制在站点目录;
  • 不在命令中输出私钥、令牌或带凭据的 URL;
  • 对部署目录做非空和非根目录检查,避免变量配置错误扩大清理范围;
  • GitHub Actions 和第三方 Action 使用明确版本,升级前先阅读变更说明;
  • 定期轮换部署密钥,成员或服务器发生变化时及时撤销旧公钥;
  • 生产 Environment 可以设置审批和分支保护,只允许受保护分支部署。

完成这些配置后,日常发布只需要提交文章并推送到 main。构建环境、图片优化和文件上传都由流水线统一执行,服务器上也不需要保留完整源码或安装 Node.js。