diff --git a/.codebuddy/plans/frontend-cicd-deploy_4b05aca9.md b/.codebuddy/plans/frontend-cicd-deploy_4b05aca9.md new file mode 100644 index 0000000..805a1a5 --- /dev/null +++ b/.codebuddy/plans/frontend-cicd-deploy_4b05aca9.md @@ -0,0 +1,85 @@ +--- +name: frontend-cicd-deploy +overview: 参考 zt-closet-backend 的 Gitea Actions CI/CD,为前端管理后台编写 deploy 工作流:构建 dist 静态资源并通过 SSH 同步到生产机 nginx 目录 /www/zt-closet-manage.lzcwy.cn。 +todos: + - id: create-deploy-workflow + content: 新建 .gitea/workflows/deploy.yaml:checkout→setup-node→npm ci→npm run build→SSH+rsync 部署 dist 到 /www/zt-closet-manage.lzcwy.cn,含备份与回滚 + status: completed + - id: verify-pipeline + content: 校验工作流 YAML 语法与触发配置,确认凭据引用与后端一致 + status: completed + dependencies: + - create-deploy-workflow +--- + +## 产品概述 + +为前端项目 `zt-closet-manage-frontend` 建立与后端一致的 CI/CD 流水线(Gitea Actions),实现「打标签自动构建 → 部署到生产机 nginx 静态目录」的全流程自动化。 + +## 核心功能 + +- 推送 `v*` 标签或手动触发(workflow_dispatch)时,自动安装依赖、类型检查并构建出 `dist` 静态产物。 +- 通过 SSH 将构建产物安全传输到生产机,部署到 nginx root 目录 `/www/zt-closet-manage.lzcwy.cn`。 +- 采用蓝绿目录原子切换部署,线上目录任何时刻都完整,失败秒级回滚,保证发布零中断。 +- 复用后端已有的 Gitea runner 与 SSH 部署凭据,无需重复配置密钥。 + +## 约束说明 + +- 前端为纯静态资源,不使用 Docker,构建产物直接放到 nginx 静态目录。 +- 前端 API baseURL 为 `/api/v1`,由生产 nginx 反代到 `127.0.0.1:8081`,构建无需改动 baseURL 与 vite 配置。 +- 产物结构(`index.html` + `/assets/` 带 hash 资源)与现有 nginx `try_files`、`/assets/` 长缓存配置匹配。 + +## 技术栈 + +- CI/CD:Gitea Actions(与后端 `zt-closet-backend` 一致),runner 环境 `ubuntu-latest`。 +- 构建:Node.js(匹配 `package.json` engines `^22.18.0 || >=24.12.0`,取 Node 22)+ npm。 +- 部署:SSH + rsync 传输到临时目录 + 蓝绿目录原子切换(mv)上线。 + +## 实现方案 + +参考后端 `.gitea/workflows/deploy.yaml` 的整体风格(name / on push tags `v*` + workflow_dispatch / concurrency / env / SSH heredoc 远端执行 / 健康检查与回滚),针对前端「纯静态资源」特点做如下调整: + +- **去掉 Docker**:前端无需镜像构建,改为 `actions/checkout` → `actions/setup-node`(Node 22,启用 npm 缓存)→ `npm ci` → `npm run build`(内含 type-check + vite build)。 +- **产物传输用 rsync**:相比 scp,rsync 支持增量传输与 `--delete`,可清理临时目录中上一版本遗留的旧 hash 资源,传输更快、更干净。 +- **蓝绿目录原子切换(核心)**:新产物先 rsync 到临时目录 `/www/.zt-closet-deploy-tmp`(绝不动线上目录);校验临时目录 `index.html` 存在后,用 `mv` 原子切换:将当前线上目录改名为备份目录,再把临时目录改名为线上目录。任何一步失败,线上目录保持原样不受影响;切换后校验失败则把备份目录 `mv` 回去,秒级回滚。 +- **凭据复用**:直接沿用后端的 secret `ZT_CLOSET_PROD_SSH_KEY` 与 vars `ZT_CLOSET_PROD_SSH_HOST`、`ZT_CLOSET_PROD_SSH_USER`,生产机同一台,避免重复配置。 + +## 关键决策与权衡 + +- **蓝绿目录原子切换 而非 直接 rsync --delete 覆盖**:直接对线上目录 `--delete` 若传输中断,会出现「旧文件已删、新文件未传完」的半残状态导致站点 404;蓝绿方式线上目录始终完整,切换是一次 `mv`,零中断、回滚快。权衡是需 nginx 进程对 `/www` 目录有 rename 权限(root 运行 nginx 通常满足),且临时目录与线上目录需在同一分区才能 `mv`。 +- **rsync 而非 scp**:增量 + `--delete`(作用于临时目录)解决 Vite 带 hash 资源累积问题,传输量小、目录干净;需生产机预装 rsync(Linux 默认自带)。 +- **不改动 vite 配置**:默认 `base='/'` 与 nginx `root` 部署完全契合,`/assets/` 长缓存与 SPA `try_files` 均已就绪,零改动最安全。 +- **健康检查用文件校验**:静态站点无 `/ping` 接口,改用「线上目录 `index.html` 是否存在」作为成功判据,简单可靠。 + +## 实现要点 + +- **性能**:setup-node 开启 npm 缓存(`cache: true`),源码未变动时 `npm ci` 复用缓存,加快重建;rsync 增量避免全量传输。 +- **可靠性**:远端脚本 `set -e`,rsync 到临时目录 → 校验 index.html → `mv` 原子切换 → 切换后校验;失败则把备份目录 mv 还原,保证发布原子性与零中断;`concurrency.group: deploy` + `cancel-in-progress: false` 防止并发部署互相打断。 +- **安全**:SSH 私钥仅从 secret 注入,写入 `~/.ssh/deploy_key`(600 权限),`StrictHostKeyChecking=no` 仅用于自动化;不引入明文密钥;不改动业务代码与请求层。 +- **爆炸半径控制**:仅新增 `.gitea/workflows/deploy.yaml` 一个文件,不动现有源码、vite 配置与 nginx 配置;标签触发不影响日常 push。 + +## 架构设计 + +沿用后端现有分层部署链路,仅将「Docker 镜像」环节替换为「静态产物 rsync + 蓝绿切换」。 + +```mermaid +flowchart LR + P[push tag v* / workflow_dispatch] --> CI[Gitea Actions Runner] + CI --> B[setup-node + npm ci + npm run build] + B --> D[dist 静态产物] + D --> R[rsync over SSH 到临时目录] + R --> V{校验 index.html} + V -->|通过| M[mv 原子切换上线] + V -->|失败| X1[保留线上旧版本] + M --> N[生产机 /www/zt-closet-manage.lzcwy.cn] + N --> X[nginx 对外提供 SPA + 反代 /api 到 127.0.0.1:8081] +``` + +## 目录结构 + +``` +/root/zt-closet-manage-frontend/ +└── .gitea/ + └── workflows/ + └── deploy.yaml # [NEW] 前端构建部署流水线。触发:push tag v* 与 workflow_dispatch。步骤:checkout → setup-node(Node22, 开 npm 缓存) → npm ci → npm run build(type-check + vite build) → SSH 密钥 + rsync 将 dist/ 同步到临时目录 /www/.zt-closet-deploy-tmp → 校验 index.html 存在 → mv 蓝绿原子切换上线(线上目录改名为备份、临时目录改名为线上),切换后校验失败则 mv 备份还原。复用后端 secret ZT_CLOSET_PROD_SSH_KEY 与 vars ZT_CLOSET_PROD_SSH_HOST/ZT_CLOSET_PROD_SSH_USER。 +``` \ No newline at end of file diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..3ebbfb8 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,99 @@ +name: 构建并部署前端 + +on: + push: + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: deploy-frontend + cancel-in-progress: false + +env: + # 生产机 nginx root 目录(静态资源部署目标) + DEPLOY_DIR: /www/zt-closet-manage.lzcwy.cn + # 蓝绿切换用的临时目录(位于 nginx root 之外,不会被对外访问) + STAGING_DIR: /www/.zt-closet-deploy-tmp + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js + uses: actions/setup-node@v4 + with: + # 匹配 package.json engines: ^22.18.0 || >=24.12.0 + node-version: '22' + cache: 'npm' + + - name: 安装依赖 + run: npm ci + + - name: 类型检查并构建 + run: npm run build + + - name: 确认构建产物 + run: | + if [ ! -f dist/index.html ]; then + echo "构建产物 dist/index.html 不存在,构建可能失败" >&2 + exit 1 + fi + echo "构建产物:" + ls -la dist + + - name: SSH 部署到生产机(蓝绿原子切换) + env: + SSH_KEY: ${{ secrets.ZT_CLOSET_PROD_SSH_KEY }} + SSH_HOST: ${{ vars.ZT_CLOSET_PROD_SSH_HOST }} + SSH_USER: ${{ vars.ZT_CLOSET_PROD_SSH_USER }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + SSHOPT="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + # 1) 先把产物 rsync 到远端临时目录(--delete 清理上一版遗留的旧 hash 资源), + # 此步骤绝不触碰线上目录,rsync 中断也不会影响线上。 + rsync -az --delete -e "ssh $SSHOPT" dist/ "$SSH_USER@$SSH_HOST:$STAGING_DIR/" + + # 2) 远端校验临时目录 + 蓝绿原子切换(mv) + ssh $SSHOPT "$SSH_USER@$SSH_HOST" 'bash -s' <<'REMOTE' + set -e + DIR="/www/zt-closet-manage.lzcwy.cn" + TMP="/www/.zt-closet-deploy-tmp" + BACKUP="/www/.zt-closet-backup-$(date +%Y%m%d%H%M%S)" + + # 校验临时目录产物完整(否则中止,线上版本不受任何影响) + if [ ! -f "$TMP/index.html" ]; then + echo "临时目录缺少 index.html,部署中止,线上版本保持不变" >&2 + exit 1 + fi + + # 首次部署时线上目录可能不存在,做兼容 + if [ -d "$DIR" ]; then + mv "$DIR" "$BACKUP" + fi + mv "$TMP" "$DIR" + + # 切换后校验,失败则秒级回滚(把备份 mv 回去) + if [ ! -f "$DIR/index.html" ]; then + echo "切换后校验失败,执行回滚" >&2 + rm -rf "$DIR" + if [ -d "$BACKUP" ]; then + mv "$BACKUP" "$DIR" + fi + exit 1 + fi + + # 成功:仅保留最近一次备份,清理更早的 + ls -1dt /www/.zt-closet-backup-* 2>/dev/null | tail -n +2 | xargs -r rm -rf + + echo "部署成功:$DIR" + REMOTE + + - name: 部署结果 + if: always() + run: echo "前端部署流程结束(目标 ${{ env.DEPLOY_DIR }})"