这个博客(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 只按格式匹配,于是把这个示例占位符当成了真密钥。它是误报,但这个文件是在用的(主题配置里指向它),不能删。
两个可选做法:
- 点 GitHub 返回的 unblock 链接手工放行 —— 但下次谁 clone 下来再推,还会被拦一次。
- 改文本让它不再匹配:把字面量拆成两段拼接。
// 关键在于把 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.xml | 200 |
| 关闭的功能是否真关 | 页面里 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 都往产物仓库写,容易产生互相覆盖的提交。
九、小结
三条值得记住的经验,都跟「跨账号」有关:
- 推送保护扫的是所有待推提交,误报只能改内容 + 重写未推历史(或在网页端点放行),追加修复提交无效。
- 跨仓库部署时,token 的归属人是目标仓库的所有者,不是源码仓库的所有者。判断信号是报错位置:早早报
Input required是没配 secret,跑到git push才 128 是没写权限。 - 发布权限先用
git push --dry-run探一次,比等 CI 跑完再猜快得多。
至于 .gitignore 和 .gitattributes,配置本身没什么技术含量,值钱的是「先想清楚每一条为什么存在」。以后换主题、换依赖,这两份文件大概率不用再动 —— 这种改一次管很久的投入,值得一开始就做对。