feat:cicd

This commit is contained in:
2026-09-12 10:59:48 +08:00
parent df4332a2a3
commit 0f88fe234b
2 changed files with 184 additions and 0 deletions
@@ -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。
```
+99
View File
@@ -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 }})"