🏗️ 自动化部署核心架构
为了不将服务器账密泄漏给 gitee/github,本文使用经典的 Webhook-Triggered CI/CD 模式,实现自动化部署流程:
代码提交: 开发者将代码推送到 Git 仓库。
Webhook 触发: Git 仓库自动发送 HTTP POST 请求(Webhook)到腾讯云 CVM 的指定端口。
Webhook Server 监听: CVM 上的 Node.js 服务接收到请求。
执行脚本: Node.js 服务运行预设的 Shell 脚本。
部署动作: Shell 脚本执行 git pull、npm install、npm run build 和部署操作。
项目更新: 新的 Vue 3 构建产物上线,通过 Nginx 暴露给用户。
✨ Webhook 交互流程 执行时序,代码推送后,系统、仓库、服务器
sequenceDiagram
participant D as Developer
participant R as Gitee Repository
participant S as Webhook Server
participant A as Deploy Server
Note over D,A: 代码推送后系统间交互流程
%% 代码推送阶段
D->>R: git push origin master
R->>R: 更新代码仓库
R->>R: 触发 Push Event
%% Webhook 触发阶段
R->>S: POST /webhook (Push Event)
Note right of R: Headers:<br/>X-Gitee-Token: sha256=xxx<br/>X-Gitee-Event: push<br/>Body: {ref, commits}
%% 服务器处理阶段
S->>S: 验证签名(X-Gitee-Token)
alt 签名验证失败
S->>R: 返回 401 Unauthorized
else 签名验证成功
S->>S: 检查分支和目标目录变更
alt 无需部署
S->>R: 返回 200 OK (skipped)
else 需要部署
S->>A: 执行部署命令
A->>A: 拉取最新代码
A->>A: 重启应用服务
A-->>S: 返回部署结果
S->>R: 返回 200 OK (success/failed)
end
end
%% 完成通知
R->>D: 显示推送结果
Note over D,R: Webhook处理完成
🛠️ 第一步:服务器基础环境配置 1. 准备工作
腾讯云 CVM (Linux) 一台。
安装 Git、Node.js 和 PM2: 1 2 3 4 5 sudo apt install nodejs npm -y npm install pm2 -gsudo apt install git -y
2. 配置 SSH 免密拉取代码 为了让服务器无需密码即可拉取代码,需要配置 SSH 密钥:
生成密钥: 在 CVM 上执行 ssh-keygen -t rsa -C "your_email@example.com",一路回车,生成公钥和私钥。
添加公钥: 将生成的公钥文件内容 (~/.ssh/id_rsa.pub) 复制。
配置仓库: 登录 Gitee 或 GitHub,进入目标仓库 的 设置 -> 部署公钥管理 (Gitee) / Deploy Keys (GitHub) ,将公钥粘贴进去并保存。
第二步:Webhook 服务搭建 使用 NestJS 9.x 实现 Webhook 服务(支持 Lerna Monorepo 目录过滤),专为 Lerna Monorepo 环境设计,支持指定目录变更过滤。
项目结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 nest-webhook/ ├── src/ │ ├── app.module.ts │ ├── main.ts │ ├── webhook/ │ │ ├── webhook.controller.ts │ │ ├── webhook.module.ts │ │ ├── webhook.service.ts │ │ └── dto/ │ │ └── webhook.dto.ts │ └── deploy/ │ ├── deploy.service.ts │ └── deploy.module.ts ├── .env.example ├── package.json └── tsconfig.json
实现步骤 1. 创建 NestJS 项目 1 2 3 4 npm i -g @nestjs/cli nest new nest-webhookcd nest-webhook npm install @nestjs/config dotenv crypto child_process axios
2. 环境变量配置 (.env) 1 2 3 4 5 6 7 WEBHOOK_SECRET=vue3-deploy-secret-123 REPO_PATH=/opt/vue3-app/your-monorepo BUILD_CMD=npm install && npm run build --workspace=web DEPLOY_PATH=/opt/vue3-app/your-monorepo/packages/web/dist WATCH_DIRS=packages/web/ TARGET_BRANCH=refs/heads/master PORT=3000
3. 核心代码实现 src/app.module.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { Module } from "@nestjs/common" ;import { ConfigModule } from "@nestjs/config" ;import { WebhookModule } from "./webhook/webhook.module" ;import { DeployModule } from "./deploy/deploy.module" ;const envFilePath = `.env.${process.env.NODE_ENV || "prod" } ` ;@Module ({ imports : [ ConfigModule .forRoot ({ isGlobal : true , envFilePath, cache : true , }), WebhookModule , DeployModule , ], })export class AppModule {}
src/main.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { NestFactory } from "@nestjs/core" ;import { ValidationPipe } from "@nestjs/common" ;import { AppModule } from "./app.module" ;import { Logger } from "@nestjs/common" ;async function bootstrap ( ) { const app = await NestFactory .create (AppModule ); const port = process.env .PORT || 3002 ; await app.listen (port); Logger .log ( `🚀 Webhook server running on http://localhost:${port} ` , "Bootstrap" ); }bootstrap ();
src/webhook/dto/webhook.dto.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 export class WebhookDto { readonly ref : string ; readonly before : string ; readonly after : string ; readonly commits : Array <{ id : string ; message : string ; added : string []; modified : string []; removed : string []; author : { name : string ; email : string ; }; timestamp : string ; }>; readonly repository : { name : string ; url : string ; homepage : string ; }; readonly sender : { login : string ; id : number ; avatar_url : string ; url : string ; }; }
src/webhook/webhook.service.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 import { Injectable , Logger } from "@nestjs/common" ;import { ConfigService } from "@nestjs/config" ;import * as crypto from "crypto" ;import { WebhookDto } from "./dto/webhook.dto" ;const querystring = require ("querystring" );@Injectable ()export class WebhookService { private readonly logger = new Logger (WebhookService .name ); constructor (private configService : ConfigService ) {} generateSignature (secret ) { const timestamp = Date .now (); const stringToSign = `${timestamp} \n${secret} ` ; const hmac = crypto.createHmac ("sha256" , secret); hmac.update (stringToSign); const signData = hmac.digest (); const base64Sign = signData.toString ("base64" ); const urlEncodedSign = querystring.escape (base64Sign); return { timestamp, signature : urlEncodedSign }; } verifySignature (timestamp, receivedSignature, tolerance = 3600000 ) { const currentTimestamp = Date .now (); if (Math .abs (currentTimestamp - timestamp) > tolerance) { console .warn ("Signature expired or timestamp invalid" ); return false ; } const secret = this .configService .get <string >("WEBHOOK_SECRET" ); const stringToSign = `${timestamp} \n${secret} ` ; const hmac = crypto.createHmac ("sha256" , secret); hmac.update (stringToSign); const signData = hmac.digest (); const expectedBase64 = signData.toString ("base64" ); const expectedSignature = querystring.escape (expectedBase64); return this .safeCompare (receivedSignature, expectedSignature); } safeCompare (a, b ) { if (a.length !== b.length ) return false ; const bufA = Buffer .from (a); const bufB = Buffer .from (b); return crypto.timingSafeEqual (bufA, bufB); } isWatchDirChanged (commits : WebhookDto ["commits" ]): boolean { const watchDirs = this .configService .get <string >("WATCH_DIRS" ).split ("," ); for (const commit of commits) { const changedFiles = [ ...(commit.added || []), ...(commit.modified || []), ...(commit.removed || []), ]; for (const file of changedFiles) { for (const dir of watchDirs) { const normalizedDir = dir.endsWith ("/" ) ? dir : `${dir} /` ; if (file.startsWith (normalizedDir)) { this .logger .log ( `Detected change in watch dir: ${file} (dir: ${dir} )` ); return true ; } } } } return false ; } }
src/webhook/webhook.controller.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import { Controller , Post , Body , Headers , HttpException , HttpStatus , Logger , } from "@nestjs/common" ;import { WebhookService } from "./webhook.service" ;import { DeployService } from "../deploy/deploy.service" ;import { WebhookDto } from "./dto/webhook.dto" ;@Controller ("webhook" )export class WebhookController { private readonly logger = new Logger (WebhookController .name ); constructor ( private readonly webhookService : WebhookService , private readonly deployService : DeployService ) {} @Post () async handleWebhook ( @Body () payload : WebhookDto , @Headers ("x-gitee-token" ) signature : string , @Headers ("x-gitee-event" ) eventType : string ) { try { if (eventType !== "push" ) { this .logger .warn (`Ignored event: ${eventType} ` ); throw new HttpException ( `Unsupported event type: ${eventType} ` , HttpStatus .BAD_REQUEST ); } if (!this .webhookService .verifySignature (payload, signature)) { this .logger .warn ("Invalid signature received" ); throw new HttpException ("Invalid signature" , HttpStatus .FORBIDDEN ); } const targetBranch = process.env .TARGET_BRANCH || "refs/heads/master" ; if (payload.ref !== targetBranch) { this .logger .log ( `Skipped non-target branch: ${payload.ref} (target: ${targetBranch} )` ); return { status : "skipped" , reason : "non-target-branch" }; } if (!payload.commits || payload.commits .length === 0 ) { this .logger .log ("No commits in push event" ); return { status : "skipped" , reason : "no-commits" }; } const hasWatchDirChange = this .webhookService .isWatchDirChanged ( payload.commits ); if (!hasWatchDirChange) { this .logger .log ( `No changes in watched directories: ${process.env.WATCH_DIRS} ` ); return { status : "skipped" , reason : "no-watched-changes" }; } this .logger .log ("Starting deployment..." ); const result = await this .deployService .deploy (); return { status : "success" , message : "Deployment completed successfully" , details : result, }; } catch (error) { this .logger .error ( `Webhook processing failed: ${error.message} ` , error.stack ); throw new HttpException ( error.response || "Internal server error" , error.status || HttpStatus .INTERNAL_SERVER_ERROR ); } } }
src/deploy/deploy.service.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import { Injectable , Logger } from "@nestjs/common" ;import { ConfigService } from "@nestjs/config" ;import { exec } from "child_process" ;import { promisify } from "util" ;const execAsync = promisify (exec);@Injectable ()export class DeployService { private readonly logger = new Logger (DeployService .name ); constructor (private configService : ConfigService ) {} async deploy (): Promise <{ stdout : string ; stderr : string }> { const repoPath = this .configService .get <string >("REPO_PATH" ); const buildCmd = this .configService .get <string >("BUILD_CMD" ); this .logger .log (`Executing deployment in ${repoPath} ` ); this .logger .log (`Build command: ${buildCmd} ` ); try { this .logger .log ("Pulling latest code..." ); await execAsync ("git pull" , { cwd : repoPath }); this .logger .log ("Building project..." ); const { stdout, stderr } = await execAsync (buildCmd, { cwd : repoPath }); this .logger .log ("Deployment completed successfully" ); this .logger .debug (`Build output: ${stdout} ` ); if (stderr) this .logger .warn (`Build warnings: ${stderr} ` ); return { stdout, stderr }; } catch (error) { this .logger .error (`Deployment failed: ${error.message} ` ); this .logger .error (`Error output: ${error.stderr} ` ); throw error; } } }
4. 模块配置 src/webhook/webhook.module.ts 1 2 3 4 5 6 7 8 9 10 11 12 import { Module } from "@nestjs/common" ;import { WebhookController } from "./webhook.controller" ;import { WebhookService } from "./webhook.service" ;import { DeployModule } from "../deploy/deploy.module" ;import { ConfigModule } from "@nestjs/config" ;@Module ({ imports : [ConfigModule , DeployModule ], controllers : [WebhookController ], providers : [WebhookService ], })export class WebhookModule {}
src/deploy/deploy.module.ts 1 2 3 4 5 6 7 8 9 10 import { Module } from "@nestjs/common" ;import { DeployService } from "./deploy.service" ;import { ConfigModule } from "@nestjs/config" ;@Module ({ imports : [ConfigModule ], providers : [DeployService ], exports : [DeployService ], })export class DeployModule {}
5. 部署到服务器 安装 PM2 进程管理器
创建 ecosystem.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 module .exports = { apps : [ { name : "nest-webhook" , script : "dist/main.js" , instances : "max" , autorestart : true , watch : false , max_memory_restart : "1G" , env : { NODE_ENV : "production" , }, env_production : { NODE_ENV : "production" , }, }, ], };
构建并启动 1 2 3 4 npm run build pm2 start ecosystem.config.js --env production pm2 save pm2 startup
6. Gitee Webhook 配置
进入 Gitee 仓库 → 管理 → WebHooks → 添加 WebHook
URL: http://your-server-ip:3000/webhook
事件: 选择 “推送事件”
Secret: 与 .env 中的 WEBHOOK_SECRET 一致
点击添加
[gitee 签名计算方法说明](https://gitee.com/help/articles/4290#article-header2)
🎉 第三步:Nginx 配置与最终测试 由于 Vue 3 项目是静态文件,需要通过 Nginx 进行反向代理和托管。
安装 Nginx (如果未安装):sudo apt install nginx -y
配置 Nginx 虚拟主机:
1 2 3 4 5 6 7 8 9 10 11 server { listen 80 ; server_name <您的域名或IP>; location / { root /home/www/my-vue3-app/dist; index index.html; try_files $uri $uri / /index.html; } }
重载 Nginx: sudo nginx -s reload
🔧 第四步:测试与验证 1. 测试有效请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 curl -X POST http://localhost:3000/webhook \ -H "Content-Type: application/json" \ -H "X-Gitee-Token: sha256=计算出的签名" \ -H "X-Gitee-Event: push" \ -d '{ "ref": "refs/heads/master", "commits": [ { "added": ["packages/web/new-file.txt"], "modified": ["packages/web/src/App.vue"], "removed": [] } ] }'
2. 测试无效请求 1 2 3 4 5 6 7 8 9 10 11 12 13 curl -X POST http://localhost:3000/webhook \ -H "Content-Type: application/json" \ -H "X-Gitee-Token: invalid_signature" \ -H "X-Gitee-Event: push" \ -d '{}' curl -X POST http://localhost:3000/webhook \ -H "Content-Type: application/json" \ -H "X-Gitee-Token: valid_signature" \ -H "X-Gitee-Event: issue_created" \ -d '{}'
(非必须)高级功能扩展 1. 添加部署钩子 在 deploy.service.ts 中添加部署前后的钩子函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 async deploy (): Promise <any > { await this .beforeDeploy (); const result = await this .executeDeployCommands (); await this .afterDeploy (result); return result; }private async beforeDeploy ( ) { this .logger .log ('Running pre-deployment tasks...' ); }private async afterDeploy (result ) { this .logger .log ('Running post-deployment tasks...' ); }
总结 这个 NestJS 9.x 实现的 Webhook 服务具有以下特点:
模块化设计 :清晰分离 Webhook 处理和部署逻辑
Monorepo 支持 :通过目录过滤只构建变更的子项目
安全可靠 :
签名验证防止伪造请求
环境变量管理敏感配置
结构化错误处理
可扩展性 :
易于添加新的通知渠道
支持部署前后钩子
可配置构建命令和目标分支
生产就绪 :
通过这个实现,你可以轻松地在 Lerna Monorepo 环境中实现”仅当指定目录变更时才触发构建部署”的自动化流程,大幅提升开发效率和资源利用率。
问题
由于腾讯云服务器资源有限 1C1G + 2C2G,编译非常吃资源,这里将编辑步骤放到 gitee 中,webhook 仅做 git pull 操作
sequenceDiagram
participant D as Developer
participant R as Gitee Repository
participant S as Webhook Server
D->>R: 推送代码至master分支
R->>R: 更新代码仓库
R->>R: 触发 CI,如果涉及Client仓库,则编译
R->>S: 编译完成后,POST /webhook (Push Event)
Note right of R: Headers:<br/>X-Gitee-Token: sha256=xxx<br/>X-Gitee-Event: push<br/>Body: {ref, commits}
%% 服务器处理阶段
S->>S: 验证签名(X-Gitee-Token)
alt 签名验证失败
S->>R: 返回 401 Unauthorized
else 签名验证成功
S->>S: 检查分支和目标目录变更
alt 无需部署
S->>R: 返回 200 OK (skipped)
else 需要部署
S->>A: 执行部署命令
A->>A: 拉取最新代码
A->>A: 重启应用服务
A-->>S: 返回部署结果
S->>R: 返回 200 OK (success/failed)
end
end
graph TD
subgraph "Gitee 流水线全流程"
A[代码推送至 Gitee 仓库] --> B{Gitee Go 触发器};
B -->|匹配分支+路径| C[拉取完整代码];
C --> D[检测 packages/client 目录变更];
D -->|有变更| E[安装依赖并构建 client];
D -->|无变更| F[跳过构建,结束];
E --> G[提交 dist 到仓库];
G --> H[调用服务器 Webhook];
H --> I[服务器拉取代码+重启服务];
F --> J[结束];
end
style A fill:#4CAF50,stroke:#333,stroke-width:2px
style I fill:#2196F3,stroke:#333,stroke-width:2px
style B fill:#FFC107,stroke:#333,stroke-width:2px
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 version: "1.0" name: Client_Deploy displayname: 前端编译# 触发条件:只有 master 分支推送时才触发 triggers: - type: git_push branches: [master] jobs: check-and-build: name: 检查变更并构建Client runs-on: ubuntu-latest # Gitee Go 支持的 Runner 类型 steps: # 1. 拉取代码(完整历史,用于 diff) - name: 检出代码 uses: actions/checkout@v4 with: token: ${{ secrets.GITEE_TOKEN }} # 仓库私人令牌(需在 Gitee 密钥管理中配置) fetch-depth: 0 # 拉取所有历史提交,确保 diff 可用 # 2. 检查 packages/client 目录变更(使用 Gitee Go 内置 diff 工具) - name: 检测Client目录变更 id: check_changes # 获取本次推送的变更文件(Gitee Go 内置环境变量) # GIT_PREVIOUS_COMMIT: 推送前的 commit ID(首次推送时为全零字符串) # GIT_COMMIT: 推送后的 commit ID run: | PREV_COMMIT="${{ env.GIT_PREVIOUS_COMMIT }}" CURRENT_COMMIT="${{ env.GIT_COMMIT }}" if [ "$PREV_COMMIT" = "0000000000000000000000000000000000000000" ]; then # 首次推送:检查所有提交的文件 CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r $CURRENT_COMMIT) else # 常规推送:检查两次提交间的变更 CHANGED_FILES=$(git diff --name-only $PREV_COMMIT $CURRENT_COMMIT) fi # 检查是否有 packages/client 下的文件变更 CLIENT_CHANGED="false" while IFS= read -r file; do if [[ "$file" == packages/client/* ]]; then CLIENT_CHANGED="true" echo "检测到变更文件: $file" break fi done <<< "$CHANGED_FILES" # 设置步骤输出(Gitee Go 通过 ::set-output 接收) echo "client_changed=$CLIENT_CHANGED" >> $GITHUB_OUTPUT # 注意:Gitee Go 兼容 GitHub Actions 输出格式 # 3. 仅当 Client 目录变更时执行构建 - name: 构建并提交ClientDist if: ${{ steps.check_changes.outputs.client_changed == 'true' }} # 正确引用步骤输出 run: | echo "===== 开始构建 packages/client =====" # 安装 pnpm(若未预装) npm install -g pnpm@8 # 安装依赖(使用 pnpm workspace) pnpm install --frozen-lockfile # 仅构建 client 包(根据实际项目调整命令) cd packages/client && pnpm run build # 提交构建产物(dist 目录) git config --local user.name "Gitee CI" git config --local user.email "ci@gitee.com" git add packages/client/dist -f # 强制添加(覆盖 .gitignore) git commit -m "chore: update client dist [skip ci]" # [skip ci] 避免循环触发 git push origin master env: GIT_AUTHOR_NAME: "Gitee CI" GIT_AUTHOR_EMAIL: "ci@gitee.com" GIT_COMMITTER_NAME: "Gitee CI" GIT_COMMITTER_EMAIL: "ci@gitee.com" # 4. 无变更时跳过构建(可选:添加日志) - name: 跳过构建通知 if: ${{ steps.check_changes.outputs.client_changed == 'false' }} run: | echo "===== 未检测到 packages/client 变更,跳过构建 =====" echo "变更文件列表:" echo "${{ steps.check_changes.outputs.changed_files }}"