Hexo 博客最容易卡住的地方,通常不是 hexo generate 本身,而是生成后的 public/ 怎么稳定发布。手动 hexo deploy 能跑,但它把构建环境、网络、Git 凭据都绑在本地机器上。换一台电脑、换一个网络、换一个 Node 版本,部署结果就可能变得不可预测。

更稳的做法是把部署流程写进仓库:源码仓库只保存 Markdown、主题配置、依赖锁和 workflow;每次 push 到 main,GitHub Actions 在云端安装依赖、生成静态站点,再把 public/ 推送到 GitHub Pages 仓库。

这篇文章记录从零配置这套流程的完整路径。例子以当前博客为准:

  • 源码仓库:magicliang/hexo-blog
  • 站点仓库:magicliang/magicliang.github.io
  • Hexo 版本:8.1.2(Butterfly 5.x 主题)
  • 包管理器:Yarn 1
  • 构建命令:npx hexo clean && npx hexo generate
  • 部署方式:peaceiris/actions-gh-pages@v4 推送 public/

目标结构

最终结构不是在一个仓库里同时维护源码和构建产物,而是把两件事拆开:

1
2
3
4
5
6
7
8
9
10
hexo-blog
├── source/_posts/ # Markdown 原文
├── _config.yml # Hexo 站点配置
├── _config.butterfly.yml # Butterfly 主题配置
├── package.json
├── yarn.lock
└── .github/workflows/deploy.yml

magicliang.github.io
└── GitHub Pages 实际读取的静态文件

部署链路是:

1
2
3
4
5
6
7
push main
-> GitHub Actions
-> yarn install --frozen-lockfile
-> hexo clean && hexo generate
-> public/
-> push 到 magicliang.github.io/main
-> GitHub Pages 更新站点

这样做的好处是清楚的:源码仓库只关心写作和配置,站点仓库只承载最终 HTML、CSS、JS 和图片。失败也好排查,Actions 日志会停在安装依赖、生成站点或推送站点中的某一步。

准备两个仓库

第一步是准备源码仓库。它至少要能在本地跑通:

1
2
3
yarn install --frozen-lockfile
npx hexo clean
npx hexo generate

如果本地都不能生成,Actions 只是把失败搬到云端,不会自动修好依赖或配置问题。

第二步是准备 GitHub Pages 仓库。个人 Pages 通常使用这个命名:

1
<username>/<username>.github.io

这个博客对应的是:

1
magicliang/magicliang.github.io

Pages 仓库需要允许被写入。对于跨仓库部署,源码仓库自带的 GITHUB_TOKEN 不够用,因为它默认只属于当前 workflow 所在仓库。要写另一个仓库,需要额外凭据。

创建最小权限 Token

部署动作只需要把生成好的静态文件写进 magicliang.github.io。因此 token 不应该拿到整个账号的 repo 权限,更适合用 fine-grained personal access token。

创建路径:

1
2
3
4
5
6
GitHub 头像
-> Settings
-> Developer settings
-> Personal access tokens
-> Fine-grained tokens
-> Generate new token

关键配置:

配置项 推荐值
Token name hexo-blog-deploy
Expiration 个人博客可选 No expiration,更保守可选 180 或 366 天
Resource owner 自己的账号
Repository access Only select repositories
Selected repository magicliang/magicliang.github.io
Repository permissions Contents: Read and write
Metadata GitHub 自动附带 Read-only

这个 token 的边界很窄:它只能写目标 Pages 仓库的内容,不能写源码仓库,也不能写账号下其他仓库。相比 classic PAT 的 repo scope,风险面小很多。

生成 token 后,页面只会显示一次。复制之后立刻写入源码仓库的 Actions secret。

配置 DEPLOY_TOKEN

进入源码仓库:

1
2
3
4
5
hexo-blog
-> Settings
-> Secrets and variables
-> Actions
-> New repository secret

填写:

1
2
Name: DEPLOY_TOKEN
Secret: 刚生成的 fine-grained PAT

workflow 里不会直接写 token 明文,而是通过:

1
${{ secrets.DEPLOY_TOKEN }}

读取 GitHub 加密保存的值。这个名字要和 workflow 中的名字完全一致。

固定 npm registry

如果 yarn.lock 里混有国内镜像、公司内网 registry 或临时代理地址,海外 GitHub runner 可能会超时。这个博客用 .yarnrc 把 registry 固定到 npm 官方源:

1
registry "https://registry.npmjs.org"

同时,yarn.lock 里的 resolved URL 也应该尽量保持可从 GitHub runner 访问。不要依赖只在本地网络能通的镜像地址。

写 deploy.yml

GitHub Actions 的 workflow 必须放在:

1
.github/workflows/deploy.yml

当前博客使用的核心配置如下:

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
name: Deploy Hexo Blog

on:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: deploy-hexo-blog-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
TZ: Asia/Shanghai

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'yarn'
cache-dependency-path: yarn.lock

- run: yarn install --frozen-lockfile

- run: npx hexo clean && npx hexo generate

- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.DEPLOY_TOKEN }}
external_repository: magicliang/magicliang.github.io
publish_branch: main
publish_dir: ./public

这份文件决定了整个部署系统的行为。GitHub 页面上的 Actions 面板只是运行和展示入口,真正可 review、可回滚的配置都在仓库里。

当前博客的实际 deploy.yml 在这段核心配置上方还有约 37 行注释,记录了触发方式、相关链接、前置配置步骤和本地 fallback 入口。把操作文档直接写在 YAML 文件头部,跟配置走同一份版本控制,比散落在 wiki 或 README 里更不容易失去同步。

TZ: Asia/Shanghai 用来让 GitHub runner 的构建时区和博客配置保持一致。Hexo 的文章日期会参与 permalink 生成,如果文章只写 date: 2026-06-20 这种没有具体时分秒的日期,云端机器的默认时区可能让路径偏到前一天。把时区写进 workflow 后,本地预览和云端产物更容易一致。

触发条件

1
2
3
4
on:
push:
branches: [main]
workflow_dispatch:

push.branches: [main] 表示只要 main 分支收到新 commit,就自动部署。workflow_dispatch 允许在 GitHub 网页手动点 Run workflow,适合不想制造新 commit、只想重新跑一次部署的场景。

这两个触发方式覆盖了日常写作和手动修复:

场景 触发方式
写完文章并 push 自动触发
Secret 刚替换,想验证是否可用 手动触发
GitHub runner 网络抖动,想重试 手动触发或 rerun

并发控制

1
2
3
concurrency:
group: deploy-hexo-blog-${{ github.ref }}
cancel-in-progress: true

博客部署不需要同一分支同时跑多次。连续 push 两个 commit 时,旧 run 继续跑只会浪费 runner 时间,甚至可能把旧版本站点推上去。concurrency 把同一分支的部署归到一个组里,新的 run 会取消还没完成的旧 run。

最小化 GITHUB_TOKEN 权限

1
2
permissions:
contents: read

这里限制的是 workflow 自带的 GITHUB_TOKEN。源码仓库只需要被 checkout 读取,不需要用它写回源码仓库。真正写 Pages 仓库的是 DEPLOY_TOKEN

这条配置不是装饰。Actions 是供应链攻击里很常见的入口,默认权限越小,某一步脚本出错或被污染时能造成的影响越小。

安装 Node 和依赖

1
2
3
4
5
6
7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'yarn'
cache-dependency-path: yarn.lock

- run: yarn install --frozen-lockfile

node-version: '22' 固定构建环境,避免 runner 镜像升级后突然换 Node 版本。cache: 'yarn' 让 Actions 缓存 Yarn 下载内容,减少每次从 registry 拉包的时间。cache-dependency-path: yarn.lock 让缓存跟 lockfile 绑定,依赖变化时自然换缓存。

--frozen-lockfile 的意义是拒绝在 CI 里偷偷改依赖树。如果 package.jsonyarn.lock 不一致,CI 应该失败,而不是在云端生成一份没有进入 Git 的临时 lockfile。

CI 把 Node 版本固定在 22。本地开发时,deploy.sh 通过 nvm 或 fnm 自动加载当前可用的 Node.js,不限制具体版本。两边 Node 版本不一致通常不影响 Hexo 生成结果,除非某个依赖有 Node 版本硬性要求。

生成静态站点

1
- run: npx hexo clean && npx hexo generate

hexo clean 清掉旧的 public/ 和缓存,避免历史构建残留。hexo generate 根据 Markdown、主题和配置重新生成完整静态站点。

这里没有调用 hexo deploy。原因是 workflow 后面已经由 peaceiris/actions-gh-pages 负责发布 public/hexo deploy 可以保留给本地 fallback,但云端部署链路不需要它。

推送到 Pages 仓库

1
2
3
4
5
6
7
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.DEPLOY_TOKEN }}
external_repository: magicliang/magicliang.github.io
publish_branch: main
publish_dir: ./public

这一步把 public/ 的内容推送到目标仓库的 main 分支。

几个字段的含义:

字段 含义
personal_token 使用 DEPLOY_TOKEN 写外部仓库
external_repository 目标 Pages 仓库
publish_branch 目标仓库接收静态文件的分支
publish_dir 本次要发布的本地目录

如果源码仓库和 Pages 仓库是同一个仓库,GITHUB_TOKEN 可能够用。但当前这种“源码仓库 -> 另一个 Pages 仓库”的形态,需要额外 token 或 deploy key。这个博客选择 fine-grained PAT,是因为它比 classic PAT 权限小,配置又比 SSH deploy key 少一步。

Pages 仓库怎么配

目标仓库 magicliang.github.io 需要启用 GitHub Pages。典型配置是:

1
2
3
4
5
6
Settings
-> Pages
-> Build and deployment
-> Source: Deploy from a branch
-> Branch: main
-> Folder: /root

对于 <username>.github.io 这种个人 Pages 仓库,GitHub 会把它发布到:

1
https://<username>.github.io

当前博客就是:

1
https://magicliang.github.io

如果使用自定义域名,还要在 Pages 页面配置域名和 HTTPS。没有自定义域名时,默认的 github.io 地址已经足够。

第一次验证

配置完成后,最小验证路径是推一个很小的 commit。比如修改一篇文章、提交、push:

1
2
3
git add source/_posts/<post>.md
git commit -m "docs(posts): add Hexo Actions deployment guide"
git push origin main

然后打开:

1
https://github.com/magicliang/hexo-blog/actions

观察最新的 Deploy Hexo Blog run。一次成功的 run 至少要看到这些阶段通过:

1
2
3
4
5
checkout
setup-node
yarn install --frozen-lockfile
npx hexo clean && npx hexo generate
Deploy to GitHub Pages

再打开目标仓库:

1
https://github.com/magicliang/magicliang.github.io

如果最新 commit 来自 Actions,说明推送已经成功。最后访问站点地址,确认文章已经出现在页面上。

常见失败

403 或 Permission denied

这通常是 token 权限不对。

优先检查:

  • Secret 名字是不是 DEPLOY_TOKEN
  • token 是否选中了 magicliang.github.io
  • token 是否有 Contents: Read and write
  • workflow 里是否写成 personal_token: ${{ secrets.DEPLOY_TOKEN }}
  • token 是否已经被 revoke 或过期

如果刚从 classic PAT 迁到 fine-grained PAT,先不要立刻删除旧 token。等新 token 跑通一次后,再 revoke 旧 token。

yarn install 失败

常见原因是 package.jsonyarn.lock 不一致,或者 lockfile 里的 registry 地址在 GitHub runner 访问不到。

处理顺序:

1
2
3
4
yarn install
git status --short
git add package.json yarn.lock .yarnrc
git commit -m "chore(deps): refresh lockfile"

如果 lockfile 里出现只适合本地网络的 registry,应统一回 npm 官方源,避免云端 runner 被网络环境卡住。

Hexo generate 失败

这类失败通常和文章 front matter、主题配置、Markdown 渲染器或依赖版本有关。先在本地跑:

1
2
npx hexo clean
npx hexo generate

本地复现后再修。不要只在 Actions 页面反复 rerun。

部署成功但页面没变

可能原因有三个:

  • GitHub Pages 还在构建或缓存,等一两分钟再刷新。
  • 目标仓库 Pages 分支不是 workflow 推送的分支。
  • 浏览器缓存了旧页面。

先看 magicliang.github.io 仓库有没有收到新 commit,再看 Pages 设置。目标仓库没有新 commit 时,问题在 Actions 推送;目标仓库有新 commit 但页面不变时,问题在 Pages 配置或缓存。

日常写作-发布循环

配置跑通之后,日常写作不需要在本地跑构建和部署。当前博客准备了两个脚本,对应两条发布路径。

lite-deploy.sh

lite-deploy.sh 只负责源码提交和 push,构建交给 Actions:

1
2
./lite-deploy.sh                              # 自动生成 commit message
./lite-deploy.sh -m "docs(posts): 新增文章" # 指定 commit message

脚本内部做三件事:检测工作区变更并提交、git pull --rebase origin maingit push origin main。push 到 main 后 GitHub Actions 自动接管,云端完成 hexo generate 和站点部署。

不要求本地安装 Node.js 或 Hexo CLI。写完文章跑一次脚本,等 Actions 变绿,站点就更新了。

deploy.sh

CI 故障、想在本地预览后再部署、或 Hexo 配置变更需要验证时,用完整本地部署:

1
./deploy.sh

deploy.sh 先委托 lite-deploy.sh --no-push 完成源码提交和 rebase,再加载本地 Node.js 环境(自动检测 nvm 和 fnm),然后依次跑 hexo cleanhexo generatehexo deploy,最后 push 源码仓库。

两条路径的分工:

lite-deploy.sh deploy.sh
构建位置 GitHub Actions(云端) 本地机器
本地需要 Node/Hexo 不需要 需要
适用场景 日常写作 CI 故障、本地预览、配置调试

日常用 lite-deploy.sh,遇到问题再切 deploy.sh

安全收尾

部署跑通后,还有几件小事值得做:

  • 删除旧 classic PAT,只保留 fine-grained PAT。
  • 保持 permissions: contents: read,不要给 workflow 默认写权限。
  • 不要把 token 写进 YAML、脚本或 README。
  • 给仓库添加 SECURITY.md,说明漏洞报告入口和处理边界。有这个文件后,GitHub 会在仓库的 Security 标签页显示安全策略入口。

用 Dependabot 管依赖更新

给源码仓库启用 Dependabot alerts,并用 .github/dependabot.yml 固定更新节奏。当前博客的配置覆盖两个生态系统——npm 依赖和 GitHub Actions:

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
version: 2
updates:
- package-ecosystem: “npm”
directory: “/”
schedule:
interval: “weekly”
day: “monday”
time: “09:00”
timezone: “Asia/Shanghai”
open-pull-requests-limit: 5
groups:
hexo-stack:
patterns:
- “hexo*”
npm-minor-patch:
update-types:
- “minor”
- “patch”

- package-ecosystem: “github-actions”
directory: “/”
schedule:
interval: “weekly”
day: “monday”
time: “09:30”
timezone: “Asia/Shanghai”
open-pull-requests-limit: 3
groups:
github-actions:
patterns:
- “*”

groups 把多个依赖更新合并到同一个 PR 里。没有分组时,Dependabot 会为每个包单独开 PR,对博客项目来说噪声太大。Hexo 相关的包归到 hexo-stack 组,其余 minor 和 patch 更新归到另一组。Actions 侧把所有更新归到一个组。两边都固定在周一早上跑,更新 PR 集中出现,不会分散在一周各处。

这些配置不复杂,但会让博客部署从”某台电脑上的一串命令”变成”仓库里可审计、可复制、可回滚的一套交付系统”。

当前博客的最小迁移清单

如果要把这套配置搬到另一个 Hexo 博客,按这个清单改:

位置 需要替换
.github/workflows/deploy.yml external_repository
GitHub Actions secret DEPLOY_TOKEN 的值
Pages 仓库设置 发布分支和目录
_config.yml 站点 URL、deploy fallback 目标
.yarnrc / yarn.lock registry 是否可被 GitHub runner 访问

真正必须改的通常只有两项:目标仓库和 token。其他配置是稳定性和可维护性加固。

参考资料