前言
在团队协作开发中,经常会遇到这样的场景:
- 多个功能分支并行开发,每个分支都需要独立的测试环境
- 测试通过后需要部署到预发布环境验证
- 最终手动控制发布到生产环境
如果每次都手动打包、上传、配置,不仅效率低下,还容易出错。本文将介绍一套完整的自动化部署方案,实现:
feature/*分支 push 后自动部署到test.example.com/feature/xxx- PR 合并到 master 后自动部署到预发布环境
example.com/preview - 生产环境
example.com需要手动触发部署 - 部署完成后自动发送飞书/钉钉通知
架构概览
#mermaid-svg-UIlwuxmq0DJ29DHp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UIlwuxmq0DJ29DHp .error-icon{fill:#552222;}#mermaid-svg-UIlwuxmq0DJ29DHp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UIlwuxmq0DJ29DHp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UIlwuxmq0DJ29DHp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UIlwuxmq0DJ29DHp .marker.cross{stroke:#333333;}#mermaid-svg-UIlwuxmq0DJ29DHp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UIlwuxmq0DJ29DHp p{margin:0;}#mermaid-svg-UIlwuxmq0DJ29DHp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster-label text{fill:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster-label span{color:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster-label span p{background-color:transparent;}#mermaid-svg-UIlwuxmq0DJ29DHp .label text,#mermaid-svg-UIlwuxmq0DJ29DHp span{fill:#333;color:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp .node rect,#mermaid-svg-UIlwuxmq0DJ29DHp .node circle,#mermaid-svg-UIlwuxmq0DJ29DHp .node ellipse,#mermaid-svg-UIlwuxmq0DJ29DHp .node polygon,#mermaid-svg-UIlwuxmq0DJ29DHp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UIlwuxmq0DJ29DHp .rough-node .label text,#mermaid-svg-UIlwuxmq0DJ29DHp .node .label text,#mermaid-svg-UIlwuxmq0DJ29DHp .image-shape .label,#mermaid-svg-UIlwuxmq0DJ29DHp .icon-shape .label{text-anchor:middle;}#mermaid-svg-UIlwuxmq0DJ29DHp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UIlwuxmq0DJ29DHp .rough-node .label,#mermaid-svg-UIlwuxmq0DJ29DHp .node .label,#mermaid-svg-UIlwuxmq0DJ29DHp .image-shape .label,#mermaid-svg-UIlwuxmq0DJ29DHp .icon-shape .label{text-align:center;}#mermaid-svg-UIlwuxmq0DJ29DHp .node.clickable{cursor:pointer;}#mermaid-svg-UIlwuxmq0DJ29DHp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UIlwuxmq0DJ29DHp .arrowheadPath{fill:#333333;}#mermaid-svg-UIlwuxmq0DJ29DHp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UIlwuxmq0DJ29DHp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UIlwuxmq0DJ29DHp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UIlwuxmq0DJ29DHp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UIlwuxmq0DJ29DHp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UIlwuxmq0DJ29DHp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster text{fill:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp .cluster span{color:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UIlwuxmq0DJ29DHp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UIlwuxmq0DJ29DHp rect.text{fill:none;stroke-width:0;}#mermaid-svg-UIlwuxmq0DJ29DHp .icon-shape,#mermaid-svg-UIlwuxmq0DJ29DHp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UIlwuxmq0DJ29DHp .icon-shape p,#mermaid-svg-UIlwuxmq0DJ29DHp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UIlwuxmq0DJ29DHp .icon-shape .label rect,#mermaid-svg-UIlwuxmq0DJ29DHp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UIlwuxmq0DJ29DHp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UIlwuxmq0DJ29DHp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UIlwuxmq0DJ29DHp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户访问
Cloudflare CDN
Azure Blob Storage
GitHub Actions 工作流
GitHub 仓库
push
trigger
trigger
feature/xxx 分支
GitHub Actions
PR 合并到 master
手动触发 / release tag
安装依赖
构建打包
上传到 Azure Blob
清理 CDN 缓存
发送通知
test/project-name/
test/project-name/feature/xxx/
prod/project-name/
prod/project-name/preview/
test.example.com
example.com
test.example.com/feature/xxx
example.com/preview
example.com
核心原理
为什么能实现分支隔离部署?
关键在于 动态 base 路径 的设计。
当构建 feature/test1 分支时:
- GitHub Actions 将分支名
feature/test1作为环境变量BASE_URL传入 - Vite 读取
BASE_URL,设置base: '/feature/test1/' - 构建产物输出到
dist/feature/test1/目录 - 上传到 Azure Blob 的
test/project-name/路径下 - 最终文件位置:
test/project-name/feature/test1/index.html
这样,不同分支的文件互不干扰,通过 URL 路径区分。
Vite base 配置的作用
base 配置决定了打包后资源的引用路径:
html
<!-- base: '/' 时 -->
<script src="/assets/main.js"></script>
<!-- base: '/feature/test1/' 时 -->
<script src="/feature/test1/assets/main.js"></script>
如果不设置正确的 base,部署到子路径后所有资源都会 404。
目录结构
project/
├── .github/
│ └── workflows/
│ ├── ci.yml # 代码检查
│ ├── deploy-test.yml # 测试环境部署
│ ├── deploy-preview.yml # 预发布环境部署
│ └── deploy-production.yml # 生产环境部署
├── src/
├── vite.config.ts
├── package.json
└── ...
完整代码实现
1. Vite 配置
typescript
// vite.config.ts
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv, type ConfigEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
// 从环境变量获取 BASE_URL,默认为根路径
// 这个值由 GitHub Actions 在构建时注入
const BASE_URL = process.env.BASE_URL || '/';
// 判断是否为预发布环境,用于代码中的条件判断
const IS_PREVIEW_ENV = BASE_URL.startsWith('/preview');
export default defineConfig((env: ConfigEnv) => {
// 加载 .env 文件中的环境变量
const viteEnv = loadEnv(env.mode, process.cwd());
return {
// 设置资源基础路径,这是实现分支隔离部署的关键
// 例如:feature/test1 分支会设置为 '/feature/test1/'
base: BASE_URL,
build: {
// 输出目录也要包含 BASE_URL,确保目录结构正确
// 例如:dist/feature/test1/
outDir: `dist${BASE_URL}`,
rollupOptions: {
output: {
// 入口文件命名,添加 hash 用于缓存控制
entryFileNames: 'assets/main-[hash].js',
// 代码分割后的 chunk 命名
chunkFileNames: 'assets/chunks/[name]-[hash].js',
// 静态资源分类存放
assetFileNames: (assetInfo) => {
const name = assetInfo?.name || '';
if (name.endsWith('.css')) {
return 'assets/styles/[name]-[hash].css';
}
if (/\.(png|jpe?g|gif|svg|webp)$/.test(name)) {
return 'assets/images/[name]-[hash].[ext]';
}
if (/\.(woff2?|ttf|eot)$/.test(name)) {
return 'assets/fonts/[name]-[hash].[ext]';
}
return 'assets/[name]-[hash].[ext]';
}
}
}
},
// 在代码中可以通过 import.meta.env.IS_PREVIEW_ENV 判断环境
define: {
'import.meta.env.IS_PREVIEW_ENV': IS_PREVIEW_ENV
},
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
};
});
2. Vue Router 配置
typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue')
}
// ... 其他路由
];
// 从 Vite 注入的环境变量获取 base 路径
// import.meta.env.BASE_URL 会自动读取 vite.config.ts 中的 base 配置
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
export default router;
3. package.json 脚本配置
json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build-testing": "vite build --mode testing",
"build-preview": "vite build --mode preview",
"build-production": "vite build --mode production",
"clean": "rimraf dist",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix"
}
}
4. 环境变量文件
bash
# .env.testing
VITE_API_BASE_URL=https://test-api.example.com
VITE_ENV=testing
# .env.preview
VITE_API_BASE_URL=https://api.example.com
VITE_ENV=preview
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_ENV=production
5. GitHub Actions 工作流
5.1 代码检查 (ci.yml)
yaml
# .github/workflows/ci.yml
# 代码质量检查工作流
# 触发时机:向 master 分支提交代码或发起 PR 时执行
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
# 检出代码
- uses: actions/checkout@v4
# 设置 Node.js 环境
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
# 如果使用私有 npm 包,需要配置 registry
# registry-url: https://npm.pkg.github.com
# scope: '@your-org'
# 缓存依赖,加速后续构建
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
# 安装依赖
- name: Install dependencies
run: npm ci
# 如果使用私有包,需要设置 token
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# 执行 lint 检查
- name: Run lint
run: npm run lint
5.2 测试环境部署 (deploy-test.yml)
yaml
# .github/workflows/deploy-test.yml
# 测试环境自动部署工作流
# 触发时机:feature/* 分支 push 时自动触发,或手动触发
# 部署结果:https://test.example.com/feature/xxx/
name: 测试环境部署
on:
# 手动触发入口
workflow_dispatch:
# feature 分支 push 自动触发
push:
branches:
- feature/**
env:
# 动态计算 BASE_URL
# 如果是 feature 分支 push,则 BASE_URL 为 /feature/xxx
# 否则为空(手动触发时部署到根路径)
BASE_URL: ${{ github.event_name == 'push' && startsWith(github.ref_name, 'feature') && format('/{0}', github.ref_name) || '' }}
# Azure Blob 配置
AZURE_BLOB_CONNECTION_STRING: ${{ secrets.AZURE_BLOB_CONNECTION_STRING }}
AZURE_BLOB_CONTAINER_NAME: ${{ vars.AZURE_BLOB_CONTAINER_NAME }}
# 测试环境部署路径
AZURE_BLOB_DESTINATION_PATH: "test/my-project"
BUILD_OUT_DIR: "dist"
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
# 使用 GitHub Environment 管理敏感配置
environment: testing
outputs:
# 输出 commit message,用于通知
firstCommitMessage: ${{ steps.getCommitMessage.outputs.firstCommitMessage }}
steps:
- uses: actions/checkout@v4
# 获取最新的 commit message
- id: getCommitMessage
name: Get commit message
run: |
firstCommitMessage=$(git log -1 --format=%s)
echo "firstCommitMessage=$firstCommitMessage" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
# 构建时注入 BASE_URL 环境变量
# Vite 会读取这个变量设置 base 路径
- name: Build
run: npm run clean && BASE_URL=$BASE_URL npm run build-testing
# 上传到 Azure Blob Storage
- name: Deploy to Azure Blob
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}'
# 单独设置 HTML 文件的缓存策略
# HTML 文件不缓存,确保用户总是获取最新版本
- name: Set HTML cache control
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}' \
--content-cache-control 'public, max-age=0' \
--pattern '*.html'
# 部署完成后发送通知
notify:
needs: deploy
runs-on: ubuntu-latest
name: Send notification
# 无论部署成功还是失败都发送通知
if: ${{ always() }}
steps:
- name: Send Feishu notification
env:
WEBHOOK_URL: ${{ vars.FEISHU_WEBHOOK }}
DEPLOY_RESULT: ${{ needs.deploy.result == 'success' && '成功' || '失败' }}
DEPLOY_ENV: '测试环境'
# 动态生成访问地址
DEPLOY_URL: ${{ format('https://test.example.com{0}/', env.BASE_URL) }}
BRANCH_NAME: ${{ github.ref_name }}
ACTOR: ${{ github.triggering_actor }}
ACTION_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
COMMIT_MESSAGE: ${{ needs.deploy.outputs.firstCommitMessage }}
run: |
# 获取中国时区时间
eventTime=$(TZ='Asia/Shanghai' date +"%Y-%m-%d %H:%M:%S")
# 构造飞书消息体
reqData="{
\"msg_type\": \"text\",
\"content\": {
\"text\": \"项目部署通知\n状态:$DEPLOY_RESULT\n环境:$DEPLOY_ENV\n分支:$BRANCH_NAME\n提交:$COMMIT_MESSAGE\n操作人:$ACTOR\n时间:$eventTime\n访问地址:$DEPLOY_URL\n构建详情:$ACTION_URL\"
}
}"
curl -X POST -H "Content-Type: application/json" -d "$reqData" $WEBHOOK_URL
5.3 预发布环境部署 (deploy-preview.yml)
yaml
# .github/workflows/deploy-preview.yml
# 预发布环境部署工作流
# 触发时机:PR 合并到 master 时自动触发,或手动触发
# 部署结果:https://example.com/preview/
name: 预发布环境部署
on:
workflow_dispatch:
pull_request:
types:
- closed
branches:
- master
env:
AZURE_BLOB_CONNECTION_STRING: ${{ secrets.AZURE_BLOB_CONNECTION_STRING }}
AZURE_BLOB_CONTAINER_NAME: ${{ vars.AZURE_BLOB_CONTAINER_NAME }}
# 预发布环境部署到生产容器的 preview 子路径
AZURE_BLOB_DESTINATION_PATH: "prod/my-project"
BUILD_OUT_DIR: "dist"
permissions:
id-token: write
contents: read
jobs:
deploy:
# 只在手动触发或 PR 被合并时执行(不是关闭未合并的 PR)
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true)
runs-on: ubuntu-latest
environment: preview
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
# 预发布环境使用 /preview 作为 base 路径
- name: Build
run: npm run clean && BASE_URL=/preview npm run build-preview
- name: Deploy to Azure Blob
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}'
- name: Set HTML cache control
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}' \
--content-cache-control 'public, max-age=0' \
--pattern '*.html'
notify:
needs: deploy
runs-on: ubuntu-latest
if: ${{ always() }}
steps:
- name: Send notification
env:
WEBHOOK_URL: ${{ vars.FEISHU_WEBHOOK }}
DEPLOY_RESULT: ${{ needs.deploy.result == 'success' && '成功' || '失败' }}
DEPLOY_ENV: '预发布环境'
DEPLOY_URL: 'https://example.com/preview/'
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_TITLE: ${{ github.event.pull_request.title }}
ACTOR: ${{ github.triggering_actor }}
ACTION_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
run: |
eventTime=$(TZ='Asia/Shanghai' date +"%Y-%m-%d %H:%M:%S")
reqData="{
\"msg_type\": \"text\",
\"content\": {
\"text\": \"项目部署通知\n状态:$DEPLOY_RESULT\n环境:$DEPLOY_ENV\nPR标题:$PR_TITLE\nPR分支:$PR_BRANCH\n操作人:$ACTOR\n时间:$eventTime\n访问地址:$DEPLOY_URL\nPR地址:$PR_URL\n构建详情:$ACTION_URL\n\n提示:预发布环境验证完成后,请手动触发正式发布\"
}
}"
curl -X POST -H "Content-Type: application/json" -d "$reqData" $WEBHOOK_URL
5.4 生产环境部署 (deploy-production.yml)
yaml
# .github/workflows/deploy-production.yml
# 生产环境部署工作流
# 触发时机:手动触发 或 推送 release/* tag
# 部署结果:https://example.com/
# 注意:生产环境部署需要谨慎,建议只允许手动触发
name: 生产环境部署
on:
# 手动触发,这是推荐的生产部署方式
workflow_dispatch:
# 也可以通过 release tag 触发
push:
tags:
- release/**
env:
AZURE_BLOB_CONNECTION_STRING: ${{ secrets.AZURE_BLOB_CONNECTION_STRING }}
AZURE_BLOB_CONTAINER_NAME: ${{ vars.AZURE_BLOB_CONTAINER_NAME }}
AZURE_BLOB_DESTINATION_PATH: "prod/my-project"
BUILD_OUT_DIR: "dist"
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
# 生产环境需要单独的 environment 配置
# 可以在 GitHub 仓库设置中配置审批流程
environment: production
outputs:
firstCommitMessage: ${{ steps.getCommitMessage.outputs.firstCommitMessage }}
steps:
- uses: actions/checkout@v4
- id: getCommitMessage
name: Get commit message
run: |
firstCommitMessage=$(git log -1 --format=%s)
echo "firstCommitMessage=$firstCommitMessage" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
# 生产环境 base 为根路径
- name: Build
run: npm run build-production
- name: Deploy to Azure Blob
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}'
- name: Set HTML cache control
uses: Azure/cli@v2.1.0
with:
inlineScript: |
az storage blob upload-batch \
--destination '${{ env.AZURE_BLOB_CONTAINER_NAME }}' \
--source ${{ env.BUILD_OUT_DIR }} \
--destination-path ${{ env.AZURE_BLOB_DESTINATION_PATH }} \
--overwrite \
--connection-string '${{ env.AZURE_BLOB_CONNECTION_STRING }}' \
--content-cache-control 'public, max-age=0' \
--pattern '*.html'
# 生产部署后清理 CDN 缓存
# 确保用户能立即访问到最新版本
- name: Clear Cloudflare cache
id: clearCache
uses: actions/github-script@v6
env:
# Cloudflare Zone ID,在 Cloudflare 控制台获取
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
with:
result-encoding: string
script: |
const { CF_ZONE_ID, CF_API_TOKEN } = process.env;
const apiUrl = `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`;
// 清理指定页面的缓存
// 也可以使用 purge_everything: true 清理所有缓存
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
files: [
'https://example.com/',
'https://example.com/about',
'https://example.com/products'
// 添加其他需要清理缓存的页面
]
})
});
const result = await response.json();
console.log('Cloudflare cache purge result:', result);
return result?.success ? 'success' : 'failure';
# 检查缓存清理结果
- name: Check cache clear result
run: |
if [ "${{ steps.clearCache.outputs.result }}" != "success" ]; then
echo "Warning: Failed to clear Cloudflare cache"
# 缓存清理失败不阻断部署,只是警告
fi
notify:
needs: deploy
runs-on: ubuntu-latest
if: ${{ always() }}
steps:
- name: Send notification
env:
WEBHOOK_URL: ${{ vars.FEISHU_WEBHOOK }}
DEPLOY_RESULT: ${{ needs.deploy.result == 'success' && '成功' || '失败' }}
DEPLOY_ENV: '生产环境'
DEPLOY_URL: 'https://example.com'
BRANCH_NAME: ${{ github.ref_name }}
ACTOR: ${{ github.triggering_actor }}
ACTION_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
COMMIT_MESSAGE: ${{ needs.deploy.outputs.firstCommitMessage }}
run: |
eventTime=$(TZ='Asia/Shanghai' date +"%Y-%m-%d %H:%M:%S")
reqData="{
\"msg_type\": \"text\",
\"content\": {
\"text\": \"生产环境部署通知\n状态:$DEPLOY_RESULT\n提交信息:$COMMIT_MESSAGE\n分支/标签:$BRANCH_NAME\n操作人:$ACTOR\n时间:$eventTime\n访问地址:$DEPLOY_URL\n构建详情:$ACTION_URL\"
}
}"
curl -X POST -H "Content-Type: application/json" -d "$reqData" $WEBHOOK_URL
配置说明
GitHub 仓库配置
需要在 GitHub 仓库的 Settings -> Secrets and variables -> Actions 中配置以下内容:
Secrets(敏感信息)
| 名称 | 说明 | 获取方式 |
|---|---|---|
AZURE_BLOB_CONNECTION_STRING |
Azure Storage 连接字符串 | Azure Portal -> Storage Account -> Access keys |
CF_ZONE_ID |
Cloudflare Zone ID | Cloudflare Dashboard -> 域名概览页右侧 |
CF_API_TOKEN |
Cloudflare API Token | Cloudflare Dashboard -> My Profile -> API Tokens |
Variables(非敏感配置)
| 名称 | 说明 | 示例值 |
|---|---|---|
AZURE_BLOB_CONTAINER_NAME |
Azure Blob 容器名 | $web |
FEISHU_WEBHOOK |
飞书机器人 Webhook 地址 | https://open.feishu.cn/open-apis/bot/v2/hook/xxx |
Environments(环境配置)
建议创建三个 Environment:
- testing - 测试环境,无需审批
- preview - 预发布环境,可选审批
- production - 生产环境,建议配置审批流程
在 Settings -> Environments 中可以为每个环境配置:
- 审批人员(Required reviewers)
- 等待时间(Wait timer)
- 部署分支限制(Deployment branches)
Azure Blob Storage 配置
1. 创建 Storage Account
bash
# 使用 Azure CLI 创建
az storage account create \
--name mystorageaccount \
--resource-group myResourceGroup \
--location eastasia \
--sku Standard_LRS
2. 启用静态网站托管
bash
az storage blob service-properties update \
--account-name mystorageaccount \
--static-website \
--index-document index.html \
--404-document index.html
启用后会自动创建 $web 容器,静态网站端点格式为:
https://mystorageaccount.z6.web.core.windows.net
3. 配置 CORS(如需要)
bash
az storage cors add \
--account-name mystorageaccount \
--services b \
--methods GET HEAD \
--origins '*' \
--allowed-headers '*'
Cloudflare 配置
1. 添加域名
在 Cloudflare Dashboard 添加域名,并将域名的 NS 记录指向 Cloudflare。
2. 配置 DNS 记录
| 类型 | 名称 | 内容 | 代理状态 |
|---|---|---|---|
| CNAME | @ | mystorageaccount.z6.web.core.windows.net | 已代理 |
| CNAME | test | mystorageaccount.z6.web.core.windows.net | 已代理 |
3. 配置 Page Rules(可选)
为了优化缓存策略,可以添加 Page Rules:
URL: example.com/*.html
设置: Cache Level = Bypass
URL: example.com/assets/*
设置: Cache Level = Cache Everything, Edge Cache TTL = 1 month
4. 创建 API Token
在 My Profile -> API Tokens -> Create Token:
- 权限:Zone -> Cache Purge -> Purge
- 区域资源:选择对应的域名
工作流程图
完整的开发部署流程
#mermaid-svg-rwvA1Ebnf89OsV8L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rwvA1Ebnf89OsV8L .error-icon{fill:#552222;}#mermaid-svg-rwvA1Ebnf89OsV8L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rwvA1Ebnf89OsV8L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rwvA1Ebnf89OsV8L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rwvA1Ebnf89OsV8L .marker.cross{stroke:#333333;}#mermaid-svg-rwvA1Ebnf89OsV8L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rwvA1Ebnf89OsV8L p{margin:0;}#mermaid-svg-rwvA1Ebnf89OsV8L .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster-label text{fill:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster-label span{color:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster-label span p{background-color:transparent;}#mermaid-svg-rwvA1Ebnf89OsV8L .label text,#mermaid-svg-rwvA1Ebnf89OsV8L span{fill:#333;color:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L .node rect,#mermaid-svg-rwvA1Ebnf89OsV8L .node circle,#mermaid-svg-rwvA1Ebnf89OsV8L .node ellipse,#mermaid-svg-rwvA1Ebnf89OsV8L .node polygon,#mermaid-svg-rwvA1Ebnf89OsV8L .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rwvA1Ebnf89OsV8L .rough-node .label text,#mermaid-svg-rwvA1Ebnf89OsV8L .node .label text,#mermaid-svg-rwvA1Ebnf89OsV8L .image-shape .label,#mermaid-svg-rwvA1Ebnf89OsV8L .icon-shape .label{text-anchor:middle;}#mermaid-svg-rwvA1Ebnf89OsV8L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rwvA1Ebnf89OsV8L .rough-node .label,#mermaid-svg-rwvA1Ebnf89OsV8L .node .label,#mermaid-svg-rwvA1Ebnf89OsV8L .image-shape .label,#mermaid-svg-rwvA1Ebnf89OsV8L .icon-shape .label{text-align:center;}#mermaid-svg-rwvA1Ebnf89OsV8L .node.clickable{cursor:pointer;}#mermaid-svg-rwvA1Ebnf89OsV8L .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rwvA1Ebnf89OsV8L .arrowheadPath{fill:#333333;}#mermaid-svg-rwvA1Ebnf89OsV8L .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rwvA1Ebnf89OsV8L .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rwvA1Ebnf89OsV8L .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rwvA1Ebnf89OsV8L .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rwvA1Ebnf89OsV8L .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rwvA1Ebnf89OsV8L .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster text{fill:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L .cluster span{color:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rwvA1Ebnf89OsV8L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rwvA1Ebnf89OsV8L rect.text{fill:none;stroke-width:0;}#mermaid-svg-rwvA1Ebnf89OsV8L .icon-shape,#mermaid-svg-rwvA1Ebnf89OsV8L .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rwvA1Ebnf89OsV8L .icon-shape p,#mermaid-svg-rwvA1Ebnf89OsV8L .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rwvA1Ebnf89OsV8L .icon-shape .label rect,#mermaid-svg-rwvA1Ebnf89OsV8L .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rwvA1Ebnf89OsV8L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rwvA1Ebnf89OsV8L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rwvA1Ebnf89OsV8L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
开发者创建 feature/xxx 分支
本地开发
Push 到远程
GitHub Actions
自动部署到测试环境
test.example.com/feature/xxx
测试验证
测试通过?
创建 PR 到 master
Code Review
Review 通过?
合并 PR
GitHub Actions
自动部署到预发布环境
example.com/preview
预发布验证
验证通过?
修复问题,重新提 PR
手动触发生产部署
GitHub Actions
部署到生产环境
example.com
清理 CDN 缓存
发送部署通知
分支与环境对应关系
#mermaid-svg-s201exWLa9x2t3IB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s201exWLa9x2t3IB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s201exWLa9x2t3IB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s201exWLa9x2t3IB .error-icon{fill:#552222;}#mermaid-svg-s201exWLa9x2t3IB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s201exWLa9x2t3IB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s201exWLa9x2t3IB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s201exWLa9x2t3IB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s201exWLa9x2t3IB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s201exWLa9x2t3IB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s201exWLa9x2t3IB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s201exWLa9x2t3IB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s201exWLa9x2t3IB .marker.cross{stroke:#333333;}#mermaid-svg-s201exWLa9x2t3IB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s201exWLa9x2t3IB p{margin:0;}#mermaid-svg-s201exWLa9x2t3IB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s201exWLa9x2t3IB .cluster-label text{fill:#333;}#mermaid-svg-s201exWLa9x2t3IB .cluster-label span{color:#333;}#mermaid-svg-s201exWLa9x2t3IB .cluster-label span p{background-color:transparent;}#mermaid-svg-s201exWLa9x2t3IB .label text,#mermaid-svg-s201exWLa9x2t3IB span{fill:#333;color:#333;}#mermaid-svg-s201exWLa9x2t3IB .node rect,#mermaid-svg-s201exWLa9x2t3IB .node circle,#mermaid-svg-s201exWLa9x2t3IB .node ellipse,#mermaid-svg-s201exWLa9x2t3IB .node polygon,#mermaid-svg-s201exWLa9x2t3IB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s201exWLa9x2t3IB .rough-node .label text,#mermaid-svg-s201exWLa9x2t3IB .node .label text,#mermaid-svg-s201exWLa9x2t3IB .image-shape .label,#mermaid-svg-s201exWLa9x2t3IB .icon-shape .label{text-anchor:middle;}#mermaid-svg-s201exWLa9x2t3IB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s201exWLa9x2t3IB .rough-node .label,#mermaid-svg-s201exWLa9x2t3IB .node .label,#mermaid-svg-s201exWLa9x2t3IB .image-shape .label,#mermaid-svg-s201exWLa9x2t3IB .icon-shape .label{text-align:center;}#mermaid-svg-s201exWLa9x2t3IB .node.clickable{cursor:pointer;}#mermaid-svg-s201exWLa9x2t3IB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s201exWLa9x2t3IB .arrowheadPath{fill:#333333;}#mermaid-svg-s201exWLa9x2t3IB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s201exWLa9x2t3IB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s201exWLa9x2t3IB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s201exWLa9x2t3IB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s201exWLa9x2t3IB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s201exWLa9x2t3IB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s201exWLa9x2t3IB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s201exWLa9x2t3IB .cluster text{fill:#333;}#mermaid-svg-s201exWLa9x2t3IB .cluster span{color:#333;}#mermaid-svg-s201exWLa9x2t3IB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s201exWLa9x2t3IB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s201exWLa9x2t3IB rect.text{fill:none;stroke-width:0;}#mermaid-svg-s201exWLa9x2t3IB .icon-shape,#mermaid-svg-s201exWLa9x2t3IB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s201exWLa9x2t3IB .icon-shape p,#mermaid-svg-s201exWLa9x2t3IB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s201exWLa9x2t3IB .icon-shape .label rect,#mermaid-svg-s201exWLa9x2t3IB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s201exWLa9x2t3IB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s201exWLa9x2t3IB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s201exWLa9x2t3IB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 环境
分支
push 自动触发
PR 合并自动触发
手动触发
手动触发
feature/xxx
master
release/v1.0.0
测试环境
test.example.com/feature/xxx
预发布环境
example.com/preview
生产环境
常见问题
Q1: 为什么资源加载 404?
最常见的原因是 base 路径配置不正确。检查以下几点:
- Vite 配置中的
base是否正确读取了BASE_URL环境变量 - 构建命令是否正确传入了
BASE_URL - Vue Router 的
createWebHistory是否使用了import.meta.env.BASE_URL
Q2: 如何支持 SPA 路由?
Azure Blob 静态网站需要配置 404 页面指向 index.html:
bash
az storage blob service-properties update \
--account-name mystorageaccount \
--static-website \
--index-document index.html \
--404-document index.html # 关键配置
Q3: 如何清理旧的分支部署?
可以添加一个定时清理的 workflow:
yaml
name: Cleanup old deployments
on:
schedule:
# 每周日凌晨 2 点执行
- cron: '0 2 * * 0'
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete old feature deployments
uses: Azure/cli@v2.1.0
env:
AZURE_BLOB_CONNECTION_STRING: ${{ secrets.AZURE_BLOB_CONNECTION_STRING }}
with:
inlineScript: |
# 列出所有 feature 目录
# 删除超过 30 天未修改的目录
# 具体实现根据需求调整
echo "Cleanup completed"
Q4: 如何回滚到上一个版本?
方案一:重新触发上一次成功的 workflow
方案二:使用 Git tag 管理版本,回滚时部署指定 tag
bash
# 创建版本 tag
git tag release/v1.0.0
git push origin release/v1.0.0
# 回滚时,手动触发 workflow 并选择对应的 tag
Q5: 如何在代码中判断当前环境?
typescript
// 判断是否为预发布环境
if (import.meta.env.IS_PREVIEW_ENV) {
console.log('当前是预发布环境');
}
// 判断构建模式
if (import.meta.env.MODE === 'production') {
console.log('生产模式');
}
// 使用自定义环境变量
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
扩展:钉钉通知
如果团队使用钉钉而不是飞书,可以替换通知步骤:
yaml
- name: Send DingTalk notification
env:
DINGTALK_WEBHOOK: ${{ vars.DINGTALK_WEBHOOK }}
DEPLOY_RESULT: ${{ needs.deploy.result == 'success' && '成功' || '失败' }}
run: |
eventTime=$(TZ='Asia/Shanghai' date +"%Y-%m-%d %H:%M:%S")
reqData="{
\"msgtype\": \"markdown\",
\"markdown\": {
\"title\": \"部署通知\",
\"text\": \"### 项目部署$DEPLOY_RESULT\n- 环境:测试环境\n- 时间:$eventTime\n- 操作人:${{ github.triggering_actor }}\"
}
}"
curl -X POST -H "Content-Type: application/json" -d "$reqData" $DINGTALK_WEBHOOK
扩展:使用 Cloudflare Pages 替代 Azure Blob
如果不想使用 Azure,可以直接使用 Cloudflare Pages:
yaml
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy dist --project-name=my-project --branch=${{ github.ref_name }}
Cloudflare Pages 的优势:
- 自动处理分支预览部署
- 内置 CDN,无需额外配置
- 免费额度较高
总结
本文介绍的多环境自动化部署方案,核心要点:
- 动态 base 路径 :通过环境变量控制 Vite 的
base配置,实现分支隔离部署 - GitHub Actions 工作流:根据不同触发条件执行不同的部署流程
- Azure Blob + Cloudflare:静态资源存储 + CDN 加速的经典组合
- 缓存策略:HTML 不缓存,静态资源长期缓存
- 通知机制:部署完成后自动通知团队
这套方案已在多个生产项目中验证,可以直接复用到新项目中。根据实际需求,可以灵活调整触发条件、部署目标和通知方式。