Monorepo在EdgeOne Pages上自动化部署实践

2026年06月08日·项目实战·#建站日志 #CICD #静态网页托管·73次浏览

摘要:本文介绍了在多前端项目 monorepo 架构下,利用 GitHub Actions 和 GitHub API 解决 master 分支更新导致所有子项目重新构建的问题。通过 dorny/paths-filter 识别代码变动,在子项目代码变更时自动将 master 同步至对应生产分支,并结合飞书 Webhook 实现了分支同步与部署结果的实时通知。

Monorepo在EdgeOne Pages上自动化部署实践

前言

最近AI编程兴起,在尝试使用 Coding Agent 写代码时,如果是前后端分离项目或者有多个子项目,为了让 Agent 能够总览全局,有两个方法:

  1. 新建一个根目录,把每个子项目 clone 到跟项目下作为一个子项目,然后在根项目下打开 Agent。
  2. 把所有项目作为一个 monorepo。

方案一自不用多说,每个项目单独 git 提交,不需要改动代码结构。 方案二则需要把所有项目整合在一起,需要改动代码结构。但是如果是新项目,使用 monorepo 在 Vibe Coding 时代,可能是更好的选择。

那么问题来了,如果是一个多前端的项目集中在一个项目中,在使用诸如 Cloudflare Pages、EdgeOne Pages 时,如果选定了生产分支为master,在项目A代码合入主干后,即使不涉及项目B,也会导致项目B重新构建部署。

那可不可以不选定 master 为生产分支呢?

异化生产分支

开发中,代码总是要合入主干分支 master 的,如果多分支演进,那么后续就会陷入不断的回合、解冲突的死循环中。

那么换个思路,代码还是回合主干,但是我们可以选择合适的时机,让 master 分支 fast-forward 同步到不同项目的生产。

那么这个合适的时机是什么呢?没错,子项目代码有改动的时候。

我们可以使用 Github Action 在我们 push 或 merge pr 时,触发子项目生产分支自动同步。

mermaid
渲染中...

核心点在于,如何在 Actions 中识别文件差异,这里要使用到dorny/paths-filter@v41这个三方 Actions 了。

在识别到代码变动后,需要触发代码同步,此时就可以使用 Github API 了。因为是同一仓库,只要开启了Write权限,就可以使用触发 Actions 的 Token 来触发代码同步。

以 push 为例,具体使用如下:

yaml
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 即可,简单。(其他平台同理)

yaml
# 汇总状态并发送飞书 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 则需要改造一下,代码如下:

javascript
/**
 * 验证 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重新构建部署,通知如下:

EO部署结果通知
EO部署结果通知

Footnotes

  1. dorny/paths-filter@v4 首页

  2. 边缘安全加速平台 EO > Pages > 消息通知