这个博客(Hexo 6.3 + 定制 matery 主题)跑了四年多,一直是「本地 hexo generate + hexo deploy 手推」的裸奔状态:没有版本控制,改错配置没有回退余地,换台电脑就得从头配环境。

这次把它整体改造了一遍,最终形态是:源码一个仓库、产物一个仓库、推送即发布。下面是完整过程和踩到的三个坑,坑比配置本身更值得记。

一、原来的样子

现状问题
没有 .git改坏了 _config.yml 只能靠记忆恢复(项目里甚至真出现过 deploy: 键被注释掉的事故)
.gitignore 只有 6 行真要 git add . 会把 node_modules(65 MB)和 public/ 一起吞进去
发布靠本地 hexo deploy每次发布都要本机装好 Node + 主题依赖,且 public/ 里堆积的旧文件可能被一起推上去

目标定得很简单:push 就是发布,本地不再需要构建。

二、目标架构

两个仓库、两个账号:

lvchungui/my-blog  ──push──▶  GitHub Actions  ──▶  bjxstpl/bjxstpl.github.io
   (源码,公开)              npm ci → test          (产物,仅 main 分支)
                               → generate → push              │
                                                              ▼
                                                   https://bjxstpl.github.io

为什么拆成两个仓库:

  • 历史干净。产物仓库里只有一次次发布的快照,不会混进源码提交;源码仓库也不会因为每次生成而多出几百个文件的 diff。
  • Pages 只认仓库本身。xxx.github.io 这个特殊仓库名决定了站点地址,源码放在哪个账号下完全不影响。
  • 可以放心公开源码,而产物仓库的历史精简,克隆快。

代价是:源码仓库的 workflow 要往另一个账号的仓库写,这一点后面直接导致了坑 3。

三、.gitignore:先想清楚什么不该进仓库

不是简单抄模板,而是按「删掉之后项目还能不能跑」逐条判断:

# ---- 依赖 ----
node_modules/

# ---- 构建产物 ----
public/
.deploy*/
db.json

# ---- 运行时产生的本地数据 ----
.trash/          # 后台删除的回收站
.backup/         # 后台改 _config.yml 前的自动备份
admin/data/      # 后台的分类/标签候选项,缺失时按文章重新推导

# ---- 临时文件 ----
.tmp*
*.log

# ---- 密钥 ----
.env
.env.*
!.env.example
*.local.yml

几个不显然的点:

  • public/ 必须忽略。它由 CI 从源码重新生成,入库只会制造无意义的大 diff。
  • db.json 必须忽略。它是 Hexo 的缓存数据库,包含全部文章的解析结果,每次生成都在变。
  • .trash/ 和 .backup/ 是我给这个项目写的本地管理后台产生的目录。前者是删除操作的回收站,后者是改配置文件前的自动备份 —— 都属于「只有本机有意义」的数据。
  • admin/data/ 也忽略了,但要说明它是可再生的:后台的分类和标签下拉候选项存在这里,文件丢了会根据现有文章重新推导,最多丢一点手工添加的候选项。
  • 顺手忽略了 .serena/、.workbuddy/ 这类 AI 工具的本地工作目录,它们跟站点没有任何关系。

四、.gitattributes:锁住换行

这一步容易被跳过,但对这个项目是必要的 —— 仓库里的文件 CRLF 和 LF 是混着的(主题配置尤其明显),Windows 上 core.autocrlf 打开后,一次 git add 就可能把整批文件标记为「全部修改」,更糟的是二进制资源被转换坏。

* text=auto

*.png  binary
*.jpg  binary
*.jpeg binary
*.gif  binary
*.webp binary
*.ico  binary
*.woff  binary
*.woff2 binary
*.ttf  binary
*.eot  binary

先把图片和字体显式标成 binary,剩下的交给 text=auto 按内容判断。这样文本文件的换行由 Git 统一管理,资源文件一个字节都不会被动。

五、工作流:一条 push 走完全程

.github/workflows/deploy.yml 的关键部分:

name: Build and Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: pages-deploy
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci --no-audit --no-fund

      - run: npm test

      - run: |
          npx hexo clean
          npx hexo generate

      - uses: peaceiris/actions-gh-pages@v4
        with:
          personal_token: ${{ secrets.PAGES_TOKEN }}
          external_repository: bjxstpl/bjxstpl.github.io
          publish_branch: main
          publish_dir: ./public
          force_orphan: false

几个设计决定:

  • **npm ci 而不是 npm install**:严格按 lock 文件装,CI 里可复现。
  • npm test 放在生成之前:这个项目有几条针对主题定制的断言(新标签页打开、导航顺序、分页行为),构建挂了比测试挂了更难查,让测试先挡一道。
  • hexo clean 必须在 generate 前:CI 是干净环境其实无所谓,但本地跑同一份命令时就靠它清掉残留。
  • **concurrency + cancel-in-progress**:连推两次提交时,让旧的那次自己取消,避免两次发布抢同一个分支。
  • **force_orphan: false**:保留产物仓库已有的提交历史,出错能回滚到上一次发布。设成 true 的话每次发布都会压成一个孤儿子提交,仓库体积小但历史断了。
  • **disable_nojekyll: false**:保留 .nojekyll,否则 GitHub Pages 会用 Jekyll 处理一遍,下划线开头的目录会消失。

六、三个坑

配置十分钟写完,剩下的时间全花在这三件事上。

坑 1:SSH 22 端口被拒

第一次探测目标仓库,直接 Connection refused:

ssh: connect to host github.com port 22: Connection refused

而 ssh.github.com:443 是通的 —— 国内网络环境下 22 端口经常被拦。表现为「有时能推,有时推不动」,很容易误判成密钥问题。

解决办法是改 ~/.ssh/config,让两个账号的 Host 别名都走 443:

Host github-site
  HostName ssh.github.com
  Port 443
  User git
  IdentityFile ~/.ssh/id_site

Host github-src
  HostName ssh.github.com
  Port 443
  User git
  IdentityFile ~/.ssh/id_src

改完原来那些 git@github-site:owner/repo.git 的写法一个字都不用动。验证方式是 ssh -T github-src,返回里会带出账号名,能顺便确认这把钥匙对应的是哪个账号 —— 多账号环境下这一步别省。

坑 2:推送被「密钥扫描」拦下

git push 直接被拒,报 GH013,理由是检测到 Tencent Cloud Secret ID:

remote: - Push cannot contain secrets
remote:   —— Tencent Cloud Secret ID ——————————
remote:    locations: commit: themes/matery/source/libs/twikoo/twikoo.all.min.js:2

翻出来一看,问题出在一个第三方评论系统的打包文件里,那里有一句表单 placeholder:

// 后 32 位已打码
ph: "示例:" + "AKID****************************"

腾讯云 Secret ID 的格式是 AKID + 32 位字符,GitHub 只按格式匹配,于是把这个示例占位符当成了真密钥。它是误报,但这个文件是在用的(主题配置里指向它),不能删。

两个可选做法:

  1. 点 GitHub 返回的 unblock 链接手工放行 —— 但下次谁 clone 下来再推,还会被拦一次。
  2. 改文本让它不再匹配:把字面量拆成两段拼接。
// 关键在于把 AK|ID 断开,运行时拼出来的值不变
ph: "示例:" + "AK" + "ID****************************"

运行时拼出来的字符串一个字节都没变,node --check 通过,但仓库里不再出现 AKID + 32 位的模式。

顺带一提,这篇总结自己也被拦了一次:第一版里我把那句 placeholder 原样贴进了 Markdown,推送时照样报 GH013,报错位置就是这篇文章本身。所以上面两个示例是打了码的 —— 只要格式像密钥,出现在哪里都会被扫,跟它在不在可执行代码里无关。

这里有个容易白忙一场的地方:推送保护扫描的是所有待推送的提交,不是最后一次提交。所以我先试着追加了一个「修复提交」,照样被拒 —— 因为前面几个提交里还留着旧内容。由于仓库还是空的、一个提交都没推上去,最终解法是把本地历史重写成一个干净的初始提交:

git checkout --orphan clean-init
git add -A && git commit -m "chore: 初始化博客源码仓库"
git branch -D main && git branch -m main
git reflog expire --expire=now --all && git gc --prune=now

最后两行是关键:只重写分支不够,旧对象还在 .git 里,gc 清掉之后才算真的干净。

坑 3:token 有,但没有写权限

CI 第一次跑,前面全绿,只挂在最后一步:

步骤结果
npm ci✅
npm test✅ 8 项通过
hexo generate✅
推送到 Pages 仓库❌ git exit 128

这里的判断很关键 —— 如果 secret 压根没配,失败会更早、报的是 Input required and not supplied: personal_token。能跑到 git push 才 128,说明 token 是配了的,只是被目标仓库拒了。

根因是账号权限:源码仓库属于 A 账号,产物仓库属于 B 账号,而 A 对 B 的仓库只有读权限。用 A 的 token 去推,必然被拒。这一点其实可以提前测出来:

git push --dry-run git@github-site:owner/site.github.io.git HEAD:refs/heads/__perm_check
# ERROR: Permission to owner/site.github.io.git denied to userA.

--dry-run 不会真的写入,但权限校验会完整走一遍,是发布链路里最便宜的一次自检。

两个解法:

  • 换 token:用目标仓库所有者账号签发 classic PAT,勾 repo,更新到源码仓库的 PAGES_TOKEN。
  • 加协作者:把 A 邀请为目标仓库的 Write 协作者,A 自己的 token 就能用了。

我选了前者 —— 为一次部署给仓库加个能改站点的协作者,权限放得太宽。换完 token 重跑,成功。

七、验证

发布链路的验证不能只看 workflow 是否绿,还得确认产物真的落地:

检查项结果
Actions 运行success(且连推两次都成功,排除侥幸)
产物仓库 main从 1cc2a2f 更新到 859748d,提交信息带源码 commit
线上首页HTTP 200,Last-Modified 刷新,36460 字节 ≈ 本地构建的 36462
归档 / 分类 / 标签 / 相册 / 文章页全部 200
/css/matery.css、/search.xml200
关闭的功能是否真关页面里 waifu / initWidget 出现 0 次

最后一条值得单说:本地配置文件里把看板娘关掉之后,页面还是有的 —— 因为后台改配置只写 YAML,不会自动重新生成。老 public/ 里躺着的还是「开启状态」的 HTML。这也是「本地手动发布」时代最容易犯的错:以为改了配置就生效了。

八、日常流程

现在写文章的流程就是:

npm run admin                      # 起本地后台,写文章、传图(自动转 WebP)
git add -A && git commit -m "..."
git push                           # 剩下的 CI 全包

不用再本地 hexo generate,也不用 hexo deploy。注意别两条路同时用 —— CI 和本地 deploy 都往产物仓库写,容易产生互相覆盖的提交。

九、小结

三条值得记住的经验,都跟「跨账号」有关:

  1. 推送保护扫的是所有待推提交,误报只能改内容 + 重写未推历史(或在网页端点放行),追加修复提交无效。
  2. 跨仓库部署时,token 的归属人是目标仓库的所有者,不是源码仓库的所有者。判断信号是报错位置:早早报 Input required 是没配 secret,跑到 git push 才 128 是没写权限。
  3. 发布权限先用 git push --dry-run 探一次,比等 CI 跑完再猜快得多。

至于 .gitignore 和 .gitattributes,配置本身没什么技术含量,值钱的是「先想清楚每一条为什么存在」。以后换主题、换依赖,这两份文件大概率不用再动 —— 这种改一次管很久的投入,值得一开始就做对。


  目录