这次迁移的目标看起来很简单:让新 Astro 博客成为 main,不再发布旧博客,GitHub Pages 可以停掉,以后每次推送都由 GitHub Actions 发布到自己的服务器。
拆开看是四个动作:Git 分支要切换,GitHub 默认分支要切换,Pages 要停掉,服务器发布链路要重新建立。少做任何一个,都会留下一个半成品。

最终结果是:Astro 检查为 0 错误、0 警告,构建生成 21 个页面;Actions 完成 SSH 发布后,公网首页返回 200,播放器的 MP3 Range 请求返回 206,服务器上的 94 首音乐也全部保留。
建立 main 不等于迁移完成
仓库最初只有 master,里面是多年前生成的 Hexo 静态文件。新 Astro 站在另一个分支上,当前文件树已经删掉旧 HTML、主题资源和 Live2D 文件,只留下新的源码。
我从新站提交建立并推送了 main。但这时 GitHub 的默认分支仍然可能是 master,Pages 也可能继续从旧分支发布。于是还要在仓库设置中把默认分支改成 main,再把 Pages 的发布来源设为 None。自己的服务器成为唯一生产环境以后,不再需要同时维护两条发布路径。
要注意,删除 master 只删分支引用,不会抹掉 Git 历史。因为新 main 继承了旧提交,过去的文件仍可从历史提交找到。我的要求只是“不再展示和维护旧文章”,所以没有重写历史。如果目标是彻底清除敏感文件,则必须另做历史重写和强制推送,风险完全不同。
先让构建独立成功,再允许部署
工作流被拆成 build 和 deploy 两个 Job。
build 在 Pull Request、main 推送和手动运行时执行:
checkout → Node.js → npm ci → astro check → astro build → 产物检查deploy 只有在以下条件同时满足时才运行:
分支是 main
不是 Pull Request
Repository Variable DEPLOY_ENABLED 等于 true
build 已成功第一次推送 main 时,服务器密钥和环境变量尚未配置,工作流只完成构建,不会产生一条预料之中的部署失败。等服务器准备完成,再设置 DEPLOY_ENABLED=true 并手动运行一次。
生产凭据放在 GitHub 的 production Environment 中:
SERVER_HOST
SERVER_PORT
SERVER_USER
SERVER_SSH_KEY
SERVER_KNOWN_HOSTS普通配置放在 Variables 中:
DEPLOY_PATH=/var/www/blog
SITE_URL=<站点地址>Secrets、Environment Variables 和 Repository Variables 名字很像,但作用域不同。尤其是部署开关,它要在 Job 启动前参与条件判断,所以我把它放在仓库级 Variables,而不是生产环境内部。
Node 的小版本也会让 CI 与本地分叉
依赖锁定后,项目实际使用的 Astro 版本要求 Node.js 至少为 22.12,部分间接依赖要求更高。本机最初是 Node 22.11,npm ci 已经开始提示 EBADENGINE。
这类提示如果被忽略,很容易出现“本地勉强能装、CI 突然不能跑”。最后我增加了 .node-version,让 Actions 使用 Node 24,并在 package.json 中声明:
{
"engines": {
"node": ">=22.19.0"
}
}版本约束让失败提前到安装阶段,省得等到构建器或原生依赖崩了才发现。
最大的发布陷阱是 Git 里没有 MP3
博客音乐库有 94 首 MP3,但这些大文件被 .gitignore 排除,不会出现在 GitHub Runner 的 checkout 中。Actions 构建出的 dist 只有页面、歌词、封面和其他静态资源。
如果把这个 dist 当作“完整真相”覆盖服务器,新页面会成功上线,94 首音乐却会全部消失。构建本身仍然是绿色的,问题只会在播放器请求时出现。

因此发布脚本在切换前做了额外处理:
解压新构建到 staging
从当前线上版本继承所有 MP3
检查 index.html
确认继承到的 MP3 数量不为 0
再激活新版本如果服务器上一个 MP3 都没有,脚本直接失败,不碰当前站点。代码仓库和媒体库是两个数据源。既然暂时没有把音频迁到对象存储或制品仓库,部署系统就不能假装 Git 包含完整站点。
SSH 有三种东西,不要混在一起
第一次配置 Actions SSH 时,最容易混淆的是私钥、公钥和服务器 Host Key。
Actions 私钥 放进 SERVER_SSH_KEY,用来证明“我是谁”
对应公钥 放进服务器 authorized_keys,允许这把私钥登录
服务器 Host Key 放进 SERVER_KNOWN_HOSTS,证明“服务器是谁”SERVER_KNOWN_HOSTS 不是服务器密码,也不是 Actions 公钥。它用于严格校验服务器身份,避免自动化在网络被劫持时把发布包交给另一台机器。生成记录后,还应通过云厂商控制台或其他可信通道核对 ED25519 指纹。
私钥从不打印到日志,也不写进仓库。服务器密码同样不应该出现在聊天、命令参数或文档里。我在配置过程中曾把密码直接发进对话窗口,这已经等同于凭据泄露;密钥登录打通后,正确收尾是立即轮换密码,而不是因为“只有自己看见”就继续使用。
命令复制也踩过一次小坑。Markdown 中的转义符被一起复制,root@host 变成了 root\@host,~/.ssh/authorized_keys 也多出反斜杠。PowerShell 出现连续的 >> 时,不代表 SSH 很慢,而是引号或管道没有闭合,Shell 仍在等待后续输入。此时先按 Ctrl+C 回到正常提示符,再复制不带提示符和转义字符的命令。SSH 密码输入时不显示字符和星号则是正常行为。
发布目录只在最后一刻切换
服务器不直接清空 /var/www/blog。每次发布使用提交 SHA 和运行次数组成版本号,把站点展开到相邻的 Releases 目录:
/var/www/.blog-releases/<commit>-<attempt>/第一轮自动发布时,原来的实体目录先移动到:
/var/www/.blog-backups/legacy-<UTC 时间>/新版本完整准备好以后,再用符号链接替换 /var/www/blog。后续发布只需要原子替换这个链接。每个版本还会记录 RELEASE 和 PREVIOUS_RELEASE,出问题时可以知道当前版本和上一目标,而不是临时猜哪个目录才是好的。

工作流使用并发锁,新的生产发布不会取消正在切换的旧发布。静态站发布很快,没有必要为了省几十秒,让两个进程同时操作同一个站点链接。
绿色 Actions 以后还要检查服务器
最终验收没有停在“Workflow success”。我检查了四层状态:
- Actions 的 Build 和 Deploy Job 都成功。
/var/www/blog已经指向这次提交对应的 Release。- Release 中仍有 94 个 MP3,并且首次发布备份存在。
- 公网首页返回 200,带
Range: bytes=0-1023的 MP3 请求返回 206。
Nginx 在本机访问 127.0.0.1 时曾返回 404,而公网地址正常返回 200。原因是虚拟主机按 Host 匹配;给本地请求带上正确的 Host 后同样返回 200。单看一次 localhost 请求,很容易把正常的站点路由误判成发布失败。
206 Partial Content 也不能省。音乐文件普通 GET 返回 200,只能证明整文件可以下载;播放器拖动进度、续播和浏览器按区间加载依赖 Range 请求。
之后我会照做的几条
- 分支、默认分支、Pages、服务器,四个状态分别确认。
- 构建和部署分开,首次上线前给部署加一个显式开关。
- Git 忽略的大文件也是生产数据,发布脚本要写明它们从哪来。
- 先在旁边备好完整版本,验证后再切链接,首次迁移留旧目录备份。
- Actions 变绿以后,还要核对 Release、资源数量、HTTP 200 和 Range 206。
- 密码一旦进了聊天或日志,就当泄露,立刻换。
没有上容器,也没有上 Kubernetes。对一个 Astro 个人站来说,GitHub Actions、SSH、Release 目录、符号链接加一份能恢复的备份,已经够用。