Monorepo在EdgeOne Pages上自动化部署实践
摘要:本文介绍了在多前端项目 monorepo 架构下,利用 GitHub Actions 和 GitHub API 解决 master 分支更新导致所有子项目重新构建的问题。通过 dorny/paths-filter 识别代码变动,在子项目代码变更时自动将 master 同步至对应生产分支,并结合飞书 Webhook 实现了分支同步与部署结果的实时通知。
前言
最近AI编程兴起,在尝试使用 Coding Agent 写代码时,如果是前后端分离项目或者有多个子项目,为了让 Agent 能够总览全局,有两个方法:
- 新建一个根目录,把每个子项目 clone 到跟项目下作为一个子项目,然后在根项目下打开 Agent。
- 把所有项目作为一个 monorepo。
方案一自不用多说,每个项目单独 git 提交,不需要改动代码结构。 方案二则需要把所有项目整合在一起,需要改动代码结构。但是如果是新项目,使用 monorepo 在 Vibe Coding 时代,可能是更好的选择。
那么问题来了,如果是一个多前端的项目集中在一个项目中,在使用诸如 Cloudflare Pages、EdgeOne Pages 时,如果选定了生产分支为master,在项目A代码合入主干后,即使不涉及项目B,也会导致项目B重新构建部署。
那可不可以不选定 master 为生产分支呢?
异化生产分支
开发中,代码总是要合入主干分支 master 的,如果多分支演进,那么后续就会陷入不断的回合、解冲突的死循环中。
那么换个思路,代码还是回合主干,但是我们可以选择合适的时机,让 master 分支 fast-forward 同步到不同项目的生产。
那么这个合适的时机是什么呢?没错,子项目代码有改动的时候。
我们可以使用 Github Action 在我们 push 或 merge pr 时,触发子项目生产分支自动同步。
渲染中...
核心点在于,如何在 Actions 中识别文件差异,这里要使用到dorny/paths-filter@v41这个三方 Actions 了。
在识别到代码变动后,需要触发代码同步,此时就可以使用 Github API 了。因为是同一仓库,只要开启了Write权限,就可以使用触发 Actions 的 Token 来触发代码同步。
以 push 为例,具体使用如下:
name: Auto Sync Prod Branches
on:
push:
branches:
- master # 仅在 master 分支收到推送时触发
# 授予安全凭证更新代码库内容的权限
permissions:
contents: write
jobs:
check-and-sync:
runs-on: ubuntu-latest
steps:
# 检出代码
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0 # 检出所有commit,用于获取历史比对。如果仓库较大,可以设置合理的commit数量
# 使用 paths-filter 检测文件变化
- name: Check file changes
id: filter
uses: dorny/paths-filter@v4
with:
base: ${{ github.event.before }} # 推送前commit
ref: ${{ github.event.after }} # 推送后commit
# filters会在命中规则后,赋值对应的outputs
filters: |
shared:
- 'web/packages/shared/**'
admin:
- 'web/packages/admin/**'
client:
- 'web/packages/client/**'
# 管理端同步
- name: Sync prod-admin from master
id: sync_admin
if: ${{ steps.filter.outputs.shared == 'true' || steps.filter.outputs.admin == 'true' }} # 如果共享包或管理端包有修改时触发
continue-on-error: true # 该步骤错误不阻塞后续同步
# 使用API同步分支,"force":false保证历史正常,如果报错,需要手动解决。
run: |
echo "管理端文件有变动,正在同步 prod-admin 分支..."
curl -f -X PATCH \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/${{ github.repository }}/git/refs/heads/prod-admin \
-d "{\"sha\":\"${{ github.sha }}\",\"force\":false}"
# 其他项目的step,不赘述
# ...同步完成后,对应分支的最近更新记录会变成 github-actions,看分支代码也应该是最新的 master 代码
通知
在代码推送到生产分支后,总不能干看着等 acitons 的执行结果吧? 推送成功后,触发EdgeOne Pages构建没有呀?部署成功没有啊? 那就对接下通知把,把结果推送到 IM 上,不干等。
分支同步结果
分支是在 Actions 里同步的,再结尾加一个推送飞书 webhook 的 step 即可,简单。(其他平台同理)
# 汇总状态并发送飞书 Webhook 通知
- name: Send Feishu Webhook Notification
if: always()
env:
FEISHU_WEBHOOK: ${{ secrets.FEISHU_WEBHOOK }} # 把飞书机器人webhook地址填到github actions的环境变量里
run: |
ADMIN_STATUS="${{ steps.sync_admin.outcome }}"
CLIENT_STATUS="${{ steps.sync_client.outcome }}"
# 定义函数,将状态转换为带 Emoji 的文本
get_status_emoji() {
case "$1" in
success) echo "✅ 成功" ;;
failure) echo "❌ 失败" ;;
skipped) echo "⏭️ 无变更" ;;
*) echo "⚠️ 未知状态" ;;
esac
}
# 提取纯分支名
BRANCH_NAME="${GITHUB_REF##*/}"
# 调用函数生成格式化后的状态文本
ADMIN_EMOJI=$(get_status_emoji "$ADMIN_STATUS")
CLIENT_EMOJI=$(get_status_emoji "$CLIENT_STATUS")
# 获取 Commit 信息
COMMIT_MSG=$(git log -1 --pretty=format:%s)
# 构造飞书卡片消息 JSON (interactive 类型更美观)
JSON_PAYLOAD=$(cat <<EOF
{
"msg_type": "interactive",
"card": {
"header": {
"title": {
"tag": "plain_text",
"content": "🚀 GitHub 分支自动同步通知"
},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**仓库:** ${{ github.repository }}\n**触发者:** ${{ github.actor }}\n**Commit:** ${COMMIT_MSG}\n**分支:** ${BRANCH_NAME}"
}
},
{
"tag": "hr"
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**管理端 (prod-admin):** ${ADMIN_EMOJI}\n**展示端 (prod-client):** ${CLIENT_EMOJI}"
}
}
]
}
}
EOF
)
# 发送 POST 请求到飞书 Webhook
curl -X POST \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"$FEISHU_WEBHOOK"在 actions 执行完整后,会受到一条飞书消息,如下:
Pages部署结果
分支同步成功后,触发 Pages 自动构建(需要把 Pages 的生产分支改成子项目生产废纸,而不是 master 分支)。此时再对接 Pages 的通知能力,就可以感知到不是结果。
这里以当前在使用的 EdgeOne Pages 举例2。
可以直接配置邮箱接收消息,但是我想自定义消息内容以及放到跟分支同步一样的地方进行提醒,所以我使用了其对应的 webhook 功能。
又因为 EO Pages 的 webhook 格式与飞书自定义机器人的webhook不一样,所以要中转下。可以使用 EO 自带的边缘函数(国内预览需要绑定备案域名,比较麻烦),但是我选择使用cloudflare worker(不需要自定义子域名)。
中转函数在 EO 官方文档有写,对接 cloudflare worker 和飞书 webhook 则需要改造一下,代码如下:
/**
* 验证 Bearer Token(可选)
*/
function verifyToken(authHeader, expectedToken) {
if (!expectedToken) return true; // 未配置则跳过验证
const parts = authHeader?.split(' ');
if (parts?.length !== 2 || parts[0] !== 'Bearer') return false;
return parts[1] === expectedToken;
}
/**
* 处理 webhook 事件 发送飞书
*/
async function handleEvent(eventType, data, env) {
const eventName = eventType === 'deployment.created' ? '🚀 开始部署' : eventType === 'deployment.succeeded' ? '✅ 部署成功' : eventType === 'deployment.failed' ? '❌ 部署失败' : '⚠️ 未知事件';
const beijingTime = new Date(data.timestamp).toLocaleString("sv-SE", {
timeZone: "Asia/Shanghai",
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false // 24小时制
});
const payload = {
"msg_type": "interactive",
"card": {
"header": {
"title": {
"tag": "plain_text",
"content": "⚒️ EO 部署通知"
},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": `**项目:** ${data.projectName}\n**事件:** ${eventName}\n**分支:** ${data.repoBranch}\n**时间:** ${beijingTime}`
}
}
]
}
}
const feishuResponse = await fetch(env.FEISHU_WEBHOOK_URL, { // FEISHU_WEBHOOK_URL 需要配置在worker环境变量
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
// 返回飞书的响应结果
const result = await feishuResponse.json();
return { code: result.code, msg: result.msg }
}
export default {
async fetch(request, env, ctx) { // 函数入口
// 健康检查
if (request.method === 'GET') {
return new Response(JSON.stringify({ status: 'ok', message: 'Webhook endpoint is ready' }), {
headers: { 'Content-Type': 'application/json' }
});
}
// 只接受 POST
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// 验证 Bearer Token(可选)
const authHeader = request.headers.get('authorization');
const webhookToken = env.EO_WEBHOOK_TOKEN; // EO_WEBHOOK_TOKEN 需要配置在worker环境变量
if (webhookToken && !verifyToken(authHeader, webhookToken)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// 解析请求体
const payload = await request.json();
const eventType = payload.eventType || payload.type;
// 处理事件
const result = await handleEvent(eventType, payload, env);
// 返回成功响应
return new Response(JSON.stringify({
success: true,
eventType,
result,
timestamp: new Date().toISOString()
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('处理错误:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};生产分支更新后,触发EO重新构建部署,通知如下: