mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5
4371 字
22 分钟
从手动上传到自动发布:我给项目接入 CI/CD 的完整复盘

一句话摘要#

这篇文章复盘了我把一个 Vue + Vite + PWA 项目从手动上传改造成 GitHub Actions 自动发布的全过程。真正有价值的部分,不只是部署自动化本身,而是如何把发布链路变成一个可重复、可验证、可排查的流程。

目录#

背景与问题#

这次折腾 CI/CD,起点其实很简单:我不想每次改完网站后,还要一遍遍手动上传。

原来的流程是:

  1. 本地改完代码。
  2. 手动上传到 GitHub。
  3. 再手动上传到腾讯云。
  4. 再手动刷新网站。

如果只是偶尔改一次,这样做还能忍。但一旦进入频繁迭代阶段,这套流程会越来越烦,而且非常容易出错。

所以这次的目标很明确:

以后只要 git push,网站就自动检查、自动打包、自动上传、自动切到新版本。

本章小结#

这次改造主要是为了减少手动发布带来的重复劳动。

最终方案#

最终跑通的链路是这样的:

  1. 本地改代码,提交到 GitHub。
  2. GitHub Actions 自动执行安装依赖、类型检查、测试和打包。
  3. GitHub Actions 通过 SSH 把打包结果传到 Lighthouse。
  4. 服务器执行发布脚本,把文件解压到新的版本目录。
  5. 服务器把 current 指向这个新版本。
  6. OpenResty 继续对外提供网站。

对应到仓库里,关键文件有两个:

  • yml工作流文件
  • OpenResty 配置

简单说,这套方案的核心思想就是:

GitHub 负责“打包和上传”,服务器负责“接收和切换版本”。

我的线上环境是:

  • 腾讯云 Lighthouse
  • 1Panel
  • OpenResty
  • Vue + Vite + PWA 前端项目

这意味着我不是纯静态托管平台,也不是 Docker 编排方案。我已经有一台稳定运行的网站服务器,所以最合适的方式不是重新迁移平台,而是保留 Lighthouse + 1Panel + OpenResty,只把“手动发布”改成“自动发布”。

本章小结#

这次方案的关键不是引入更多基础设施,而是在现有环境上补齐自动化发布能力,让 GitHub Actions 和服务器各自负责清晰的一段。

实践过程#

步骤 1:调整服务器目录结构#

以前网站文件直接放在一个固定目录里,比如:

/opt/1panel/www/sites/calorie/index

为了支持自动发布和多版本切换,我把目录改成了这种结构:

/opt/1panel/www/sites/calorie/
index
releases/
first/
1bc469d390d5d8f2094763ce9273f05aef7e1d59/
current -> releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59

这里最关键的是两个概念:

  • releases/:保存每次发布后的版本目录。
  • current:当前线上正在使用的版本。

这样以后每次发布,都只是新建一个版本目录,把新文件解压进去,再把 current 切过去。

步骤 2:让 OpenResty 读取 current#

服务器这边不是系统自带 Nginx,而是 1Panel 管理的 OpenResty。

最后生效的思路是:

  • 宿主机路径用 /opt/1panel/...
  • OpenResty 配置里的路径用 /www/sites/...

OpenResty 配置里核心是这两句:

root /www/sites/calorie/current;
index index.html;

此外,Vue Router 我使用的history路由模式,所以必须加上:

location / {
try_files $uri $uri/ /index.html;
}

否则像 /history/profile 这种页面,直接刷新就会 404。

步骤 3:编写服务器发布脚本#

服务器上放了一个简单脚本,用来完成“解压新版本 + 切换 current”。

最终版本是:

#!/usr/bin/env bash
set -e
APP_DIR="/opt/1panel/www/sites/calorie"
ARCHIVE_PATH="$1"
VERSION_NAME="$2"
RELEASE_DIR="$APP_DIR/releases/$VERSION_NAME"
mkdir -p "$RELEASE_DIR"
tar -xzf "$ARCHIVE_PATH" -C "$RELEASE_DIR"
ln -sfn "releases/$VERSION_NAME" "$APP_DIR/current"
rm -f "$ARCHIVE_PATH"
cd "$APP_DIR/releases"
ls -1dt */ | tail -n +6 | xargs -r rm -rf
IMPORTANT

这里最关键的一行是 ln -sfn "releases/$VERSION_NAME" "$APP_DIR/current"。它必须使用相对路径软链接,不能写成指向宿主机绝对路径的软链接。

步骤 4:生成 GitHub 用的 SSH 密钥#

要让 GitHub 自动上传文件到 Lighthouse,就得让 GitHub 能通过 SSH 登录服务器。

流程是:

  1. 本地生成一对专门用于部署的密钥。
  2. 把公钥放到服务器的 authorized_keys
  3. 把私钥放到 GitHub Secrets。

我在本地测试过:

Terminal window
ssh -i "$env:USERPROFILE\.ssh\calorie_lighthouse" root@服务器ip地址

能正常登录,就说明这一步成功。

步骤 5:配置 GitHub Secrets#

在仓库的设置里添加这 4 个 Secrets:

  • LIGHTHOUSE_HOST:服务器的 IP 地址
  • LIGHTHOUSE_PORT:服务器的 SSH 端口
  • LIGHTHOUSE_USER:SSH 登录用户名
  • LIGHTHOUSE_SSH_KEY:生成的私钥私钥内容
小提醒
  • GitHub 里放的是私钥文本。
  • 服务器里放的是公钥。
  • 不要把 .pub 公钥文件填进 GitHub。

步骤 6:添加 GitHub Actions 工作流#

工作流文件位置:

.github/workflows/deploy.yml

它做的事情是:

  1. 拉代码。
  2. 安装依赖。
  3. pnpm type-check
  4. pnpm test
  5. pnpm build
  6. dist 同步到服务器的新版本目录。
  7. current 切到新版本。

一开始我用的是 tar -czf dist.tar.gz -C dist .,也就是先压缩,再上传,再解压。

这个方案很直观,但如果站点里有大量图片、音频这类已经压缩过的静态资源,tar.gz 的收益会很小。比如一个 699MB 的 dist,压缩后可能仍然有 680MB 以上。

后面我把上传方式改成了 rsync 增量同步。核心命令是:

- name: Sync dist to server
run: |
rsync -az --delete \
--link-dest=/opt/1panel/www/sites/calorie/current \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.LIGHTHOUSE_PORT }} -o ServerAliveInterval=30 -o ServerAliveCountMax=20" \
dist/ \
"${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}:/opt/1panel/www/sites/calorie/releases/${{ github.sha }}/"

这里的关键不是 rsync 本身,而是 --link-dest

它会拿新版本目录和当前线上版本 current 做比较:

  • 没变化的文件:直接硬链接上一版文件,不重新上传。
  • 有变化的文件:才从 GitHub Actions 传到服务器。
  • 服务器多出来的旧文件:通过 --delete 清理掉。

同步完成后,再执行:

- name: Activate release
run: |
ssh -i ~/.ssh/deploy_key \
-p "${{ secrets.LIGHTHOUSE_PORT }}" \
"${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}" \
"cd /opt/1panel/www/sites/calorie && test -f releases/${{ github.sha }}/index.html && ln -sfn releases/${{ github.sha }} current && cd releases && ls -1dt */ | tail -n +6 | xargs -r rm -rf"

这里先检查新版本目录里是否存在 index.html,再切换 current。这样即使同步中途失败,线上仍然停留在旧版本,不会切到半成品目录。

本章小结#

整套实践过程可以拆成三层:GitHub Actions 负责构建和增量同步,服务器负责版本目录和软链接切换,OpenResty 负责读取当前版本并提供访问。

踩坑复盘#

这部分是整次搭建里最有价值的部分。

坑 1:pnpm/action-setup 版本冲突#

第一次跑 GitHub Actions 时,卡在了:

Error: Multiple versions of pnpm specified

原因是 package.json 里已经有:

"packageManager": "pnpm@10.28.0"

但工作流里又写了:

with:
version: 10

这相当于在同一个地方声明了两份版本信息。

最后修法很简单:删除工作流里的 version: 10,保留 package.json 里的版本作为唯一来源。

坑 2:Lighthouse 上没有 nginx 命令#

一开始我想直接执行:

sudo nginx -t
sudo systemctl reload nginx

结果报错:

sudo: nginx: command not found
Failed to reload nginx.service: Unit nginx.service not found.

这并不是配置错了,而是因为这台服务器不是系统直接装的 Nginx,而是 1Panel 管理的 OpenResty。

所以后面真正生效的操作方式不是 systemctl reload nginx,而是:

  • 通过 1Panel 修改站点配置。
  • 在 1Panel 里点击“重载”。

坑 3:宿主机路径和 OpenResty 容器路径不是一回事#

这是本次最大的坑。

我一开始以为网站真实目录是:

/opt/1panel/www/sites/calorie/

所以 OpenResty 配置里也写成了:

root /opt/1panel/www/sites/calorie/current;

结果网站一直 500。

后面才发现:

  • 在服务器 shell 里看到的是宿主机路径:/opt/1panel/...
  • OpenResty 实际工作时看到的是容器里的路径:/www/sites/...

所以正确做法是:

  • shell 命令里用 /opt/1panel/...
  • OpenResty 配置里用 /www/sites/...

这类容器映射问题,如果不搞清楚,很容易一直以为“目录都在,为什么网站还是坏的”。

坑 4:current 软链接用了绝对路径,导致 500#

这次最关键的根因是:一开始我让 current 指向了宿主机绝对路径:

current -> /opt/1panel/www/sites/calorie/releases/xxx

看起来没问题,但 OpenResty 读这个链接时会顺着跳到 /opt/1panel/...,而对它来说那个路径并不存在。

于是它不断 fallback 到 /index.html,最后在日志里出现:

rewrite or internal redirection cycle while internally redirecting to "/index.html"

最后修法是把 current 改成相对路径软链接:

current -> releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59

而不是:

current -> /opt/1panel/www/sites/calorie/releases/...

改完后,网站立刻恢复正常。

坑 5:浏览器显示旧页面,不代表服务器正常#

这是一个很迷惑人的现象。

GitHub Actions 明明已经成功了,服务器里的 current 也已经切到了新版本,但浏览器里看起来网站“完全没变”。

最后排查发现:

  • 服务器真实响应其实已经 500 了。
  • 浏览器里显示的是旧的 PWA 缓存页面。

因为这个项目开了 PWA:

这意味着电脑和手机都可能继续显示旧缓存。哪怕服务器已经挂了,用户表面上也可能还能打开旧页面。

WARNING

排查线上问题时,应该先看服务器真实返回,再看浏览器表现。浏览器页面能打开,不等于服务器当前状态一定正常。

像这次,如果先执行:

curl -I https://your-domain.com

就能很快发现服务器返回的是 500,而不是继续围着“为什么页面没更新”打转。

本章小结#

这次踩坑集中在三类问题上:工具链配置重复、服务环境与预期不一致、浏览器缓存掩盖真实状态。真正节省时间的不是反复试,而是尽快确认每一层的真实状态。

增量部署的实现#

增量部署这件事,表面上看是“少传点文件”,但真正要解决的是两个问题:

  1. 怎么判断哪些文件没变。
  2. 怎么保证同步失败时不影响线上版本。

最终我采用的是 rsync + --link-dest + current 软链接 这套组合。

为什么不是继续用压缩包#

压缩包方案的链路是:

dist -> dist.tar.gz -> scp 到服务器 -> tar 解压 -> 切 current

它的优点是简单,所有文件都在一个包里,服务器只要解压就行。

但它有一个明显缺点:每次都是全量上传。

如果项目产物主要是 HTML、CSS、JS,tar.gz 的压缩收益通常还不错。但如果项目里有大量 JPG、PNG、MP3 这类文件,它们本身已经压缩过了,再套一层 gzip 基本省不了多少体积。

所以当 dist 里有几百 MB 的相册图片时,压缩包部署就会变成:

每次 push,都重新上传几百 MB。

这会让 GitHub Actions 到服务器之间的网络抖动被无限放大。上传一旦中断,服务器上还可能留下一个不完整的 .tar.gz

rsync 本身可以比较源目录和目标目录,只传变化部分。

但如果每次发布都同步到一个全新的 commit 目录,比如:

releases/e25e77d64d7eaac0dc81134f502456acade290de/

这个目录一开始是空的,rsync 仍然会认为所有文件都需要传一遍。

真正让它变成增量的是 --link-dest

rsync -az --delete \
--link-dest=/opt/1panel/www/sites/calorie/current \
dist/ \
root@服务器:/opt/1panel/www/sites/calorie/releases/<commit-sha>/

这行命令的意思是:

生成一个新的 release 目录,但如果某个文件和 current 里的文件完全一样,就不要重新上传,而是在新 release 里创建一个指向旧文件 inode 的硬链接。

这样看起来每个 release 都是完整目录:

releases/
old-sha/
new-sha/
current -> releases/new-sha

但磁盘和网络层面并不是每次都复制一整份。没变的大图、音频、字体文件,会直接复用上一版。

为什么还要保留 current#

current 不只是给 OpenResty 读取当前版本,它还是增量部署的参照物。

部署时:

  1. rsync 参考 current,生成新的 release 目录。
  2. 同步完成后,检查新目录里是否有 index.html
  3. 检查通过后,才执行 ln -sfn releases/<commit-sha> current
  4. OpenResty 继续读取 /www/sites/calorie/current

这个顺序很重要。

如果同步失败,current 还指向旧版本,线上不会受影响。只有新版本完整同步成功后,才会切换流量。

和压缩包部署相比,它解决了什么#

增量部署解决了三个实际问题。

第一,上传量变小。

文章、样式、小脚本改动时,只需要传少量变化文件。几百 MB 的相册图片不会每次重新上传。

第二,失败影响更小。

同步失败时,新 release 最多是半成品,但 current 没切过去,线上仍然是旧版本。

第三,回滚方式不变。

因为最终结构仍然是:

current -> releases/<version>

所以回滚还是同一套命令:

ln -sfn releases/<old-version> current

增量部署只是改变“新 release 怎么生成”,没有改变“线上版本怎么切换”。

本章小结#

增量部署的核心不是简单地把 scp 换成 rsync,而是让 rsync 通过 --link-dest=current 复用上一版文件,再用 current 软链接保证发布切换的原子性。这样既减少传输量,也保留了多版本发布和快速回滚的能力。

跑通后的完整链路#

到最后,整套发布链路是这样工作的:

  1. 本地改代码。
  2. 提交到 main
  3. GitHub Actions 自动运行。
  4. 通过检查、测试、构建后,把 dist/ 增量同步到 Lighthouse 的新 release 目录。
  5. 服务器确认新 release 存在 index.html,再把 current 切到新版本。
  6. OpenResty 继续从 /www/sites/calorie/current 对外提供网站。
  7. 网站更新完成。

这次成功的证据是:

  • GitHub Actions 成功。
  • deploy.sh 存在且可执行。
  • current 指向最新提交目录。
  • 网站恢复正常访问。

从这次经历里,我最终总结出几个经验。

第一,先跑通最短链路,不要一开始追求完美。

一开始我其实想做得很“高级”:版本目录、自动切换、可回滚、兼容 1Panel 都要一起上。结果越是这样,越容易踩环境差异坑。

更稳的做法是:

  • 先做最短可用链路。
  • 每一步都单独验证。
  • 哪一步坏了就只查那一步。

第二,服务器“看到目录”不等于服务“能读取目录”。

宿主机上执行:

ls -l /opt/1panel/www/sites/calorie

看到一切正常,不代表 OpenResty 真能按同样路径读取。

以后碰到 1Panel、Docker、容器环境时,第一反应应该是:

我在 shell 里看到的路径,和服务进程看到的路径,是不是同一个?

第三,浏览器看到的页面,不一定是真实线上状态。

PWA、Service Worker、浏览器缓存都会让你误判。

以后如果怀疑“网站没更新”,应该先执行:

curl -I 域名

先看真实响应是 200404 还是 500。如果浏览器还能打开,但 curl500,那大概率就是缓存干扰了判断。

第四,相对路径软链接在这种场景下比绝对路径更稳。

这次问题最后其实就卡在一个很小的细节上:

  • 绝对路径软链接:看上去明确,但跨宿主机和容器容易翻车。
  • 相对路径软链接:在同一挂载目录下更稳。

这个坑看起来很小,但杀伤力非常大。

本章小结#

跑通之后回看,CI/CD 的价值不只是自动上传文件,而是让发布过程中的每一步都有明确职责,也都有办法验证。

后续优化#

虽然现在这套 CI/CD 已经能用了,但后面还有几件事可以继续优化。

1. 限制发布权限#

比如:

  • main 分支加保护。
  • 不允许随便直接 push。
  • 至少减少误操作风险。

2. 不再直接用 root 做部署#

这次为了尽快打通链路,部署用户直接用了 root

长期来说,更稳妥的方式是:

  • 单独建一个部署用户。
  • 只给它必要权限。

3. 进一步处理 PWA 缓存更新体验#

现在虽然项目已经有更新提示逻辑,但出了线上故障时,缓存也确实容易干扰判断。

后面还可以继续优化:

  • 缓存更新策略。
  • 强制刷新提示。
  • 发布后的缓存验证方式。

本章小结#

现在的链路已经可用,后续优化重点应该放在权限收敛、发布安全和缓存体验上,而不是继续堆复杂度。

总结#

这次搭 CI/CD,表面上看只是“自动发布网站”,但实际踩到的问题非常真实:

  • CI 工具版本冲突。
  • 1Panel 和系统 Nginx 的差异。
  • 宿主机路径与容器路径不一致。
  • 绝对路径软链接带来的隐藏问题。
  • PWA 缓存掩盖真实线上错误。

最后真正让我把这套流程搞通的,不是“多改几次试试看”,而是这几个排查习惯:

  • 先确认哪一层成功了,哪一层失败了。
  • 不拿浏览器现象当唯一依据。
  • 用日志和真实 HTTP 返回来判断问题。
  • 把环境差异搞清楚,而不是只看“目录在不在”。

现在回头看,这套 CI/CD 的价值不只是省掉手动上传,更重要的是:

它把发布过程变成了一个可重复、可验证、可排查的流程。

这才是自动化真正有意义的地方。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从手动上传到自动发布:我给项目接入 CI/CD 的完整复盘
https://blog.moxiaoshuai.fun/2026-05-持续集成与持续交付-给项目添加自动部署工作流/
作者
莫莫
发布于
2026-05-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
示例歌曲
示例艺人
封面
示例歌曲
示例艺人
0:00 / 0:00