在 Polyrepo 架构下为 Fastify 与 Pinia 项目构建基于路径感知的 GitHub Actions 高效 CI 流水线


我们的团队维护着一套典型的 Polyrepo 架构:一个独立的 Git 仓库 service-api 存放基于 Fastify 的后端服务,另一个仓库 app-web 存放基于 Vue 3 和 Pinia 的前端应用。这种物理隔离赋予了前后端团队极大的自主权,但也给我们的持续集成(CI)带来了持续的痛苦。最初的 CI 设置简单粗暴,任何对 service-api 的提交都会触发完整的构建、测试和容器化流程,同样,app-web 的任何改动也会触发它自己的全套流程。

问题很快就显现了。修改后端 README.md 中的一个错字,会触发长达15分钟的 CI/CD 流水线,消耗宝贵的 GitHub Actions 分钟数。更糟糕的是,我们无法有效处理跨库依赖。如果后端更改了 API 契约,理想情况下应该触发前端的契约测试,但这在我们的原始设置中根本不可能实现。成本和效率的双重压力迫使我们必须重新设计整个 CI 体系。

这是我们最初在 service-api 仓库中的 .github/workflows/ci.yml,一个典型但低效的例子:

# .github/workflows/ci.yml in service-api repository
# WARNING: This is the initial inefficient implementation.

name: API CI Pipeline

on:
  push:
    branches:
      - main
      - 'release/**'
  pull_request:
    branches:
      - main

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm' # Basic caching, but not enough

      - name: Install dependencies
        run: npm ci

      - name: Run Lint
        run: npm run lint

      - name: Run Unit Tests
        run: npm test

      - name: Build Docker Image
        # This step is expensive and often unnecessary
        run: |
          docker build -t my-org/service-api:${{ github.sha }} .
          # In a real scenario, this would be followed by pushing to a registry

这个工作流的问题显而易见:它对变更集的内容一无所知,每一次提交都被同等对待,造成了巨大的资源浪费。

初步构想:引入路径过滤

解决问题的第一个直观思路是使用 GitHub Actions 的 pathspaths-ignore 过滤器。只有当特定目录下的文件发生变化时,才触发工作流。这是一个巨大的进步,可以立即过滤掉大量无关的变更,比如文档、.md 文件或 .gitignore 的修改。

我们迅速在 service-api 仓库中应用了这个策略:

# .github/workflows/ci.yml in service-api (version 2 with path filtering)
name: API CI Pipeline - Path Aware

on:
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - 'test/**'
      - 'package.json'
      - 'package-lock.json'
      - '.dockerignore'
      - 'Dockerfile'
  pull_request:
    branches:
      - main
    paths:
      - 'src/**'
      - 'test/**'
      - 'package.json'
      - 'package-lock.json'
      - '.dockerignore'
      - 'Dockerfile'

# ... jobs remain the same

前端 app-web 仓库也做了类似的改造。这一改动立竿见影,CI 的运行次数大幅下降。但很快,我们发现了这种简单方法的致命缺陷:它无法处理隐式依赖。在我们的项目中,service-api 仓库内有一个 openapi.yaml 文件,它定义了前后端通信的契约。当这个文件发生变更时,service-api 自身的核心源码并未改动,因此其 CI 不会运行(如果我们没有将它加入 paths 列表)。然而,前端 app-web 却需要基于这个新的契约重新生成客户端代码并运行集成测试。简单的 paths 过滤无法表达这种跨仓库的依赖关系。

我们陷入了一个两难境地:要么将所有可能影响其他仓库的文件都加入 paths 列表,但这会让列表变得臃肿不堪且难以维护;要么接受这种依赖关系的脱节,手动去触发下游的 CI。两者都不是一个工程上可靠的方案。

技术选型决策:走向集中式工作流与动态矩阵

问题的根源在于,CI 的决策逻辑被分散在各个仓库中,每个工作流都只“看得到”自己仓库内的变更。为了解决这个问题,我们需要一个“上帝视角”的协调者,它能感知到所有相关仓库的变更,并据此动态地决定需要执行哪些任务。

我们决定采用一种集中式的调度模式:

  1. 创建一个独立的、专门用于存放 CI/CD 逻辑的仓库,例如 devops-workflows
  2. 使用 GitHub Actions 的可复用工作流 (Reusable Workflows) 功能。
  3. 主工作流(调度器)将被所有代码仓库的变更触发。
  4. 调度器首先拉取所有相关仓库的代码,然后通过 git 命令分析本次提交真正影响了哪些项目(api, web, 或两者)。
  5. 基于分析结果,动态生成一个执行矩阵(strategy.matrix),只为受影响的项目并行启动构建和测试任务。

这种方案的核心在于将决策逻辑(“应该运行什么?”)与执行逻辑(“如何运行?”)分离。

步骤化实现:构建智能调度流水线

第一步:建立可复用的执行单元

首先,我们在 devops-workflows 仓库中为后端和前端分别创建可复用的工作流。这些工作流只负责“如何做”,比如如何为 Fastify 服务安装依赖、运行测试和构建 Docker 镜像。

这是后端的执行单元 .github/workflows/reusable-api-ci.yml:

# .github/workflows/reusable-api-ci.yml in devops-workflows repository

name: Reusable API CI

on:
  workflow_call:
    inputs:
      ref:
        description: 'The git ref to checkout'
        required: true
        type: string
    secrets:
      DOCKER_HUB_TOKEN:
        required: true

jobs:
  build-and-test-api:
    runs-on: ubuntu-latest
    container: node:18-bullseye # Using a specific container for consistency

    steps:
      - name: Checkout API Repository
        uses: actions/checkout@v3
        with:
          repository: 'MyOrg/service-api' # Replace with your repo
          ref: ${{ inputs.ref }}
          path: 'service-api'

      - name: Configure npm for private packages (if any)
        # In a real project, you might need to authenticate with a private registry
        run: echo "Configuring npm..."
        working-directory: ./service-api

      - name: Optimized Dependency Caching
        uses: actions/cache@v3
        id: npm-cache
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node18-npm-${{ hashFiles('service-api/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node18-npm-

      - name: Install Dependencies
        run: npm ci
        working-directory: ./service-api
        env:
          # This helps avoid some permission issues inside Docker
          npm_config_cache: /home/runner/work/_temp/.npm

      - name: Run Linter
        run: npm run lint
        working-directory: ./service-api

      - name: Run Unit & Integration Tests
        run: npm test
        working-directory: ./service-api
        env:
          NODE_ENV: test
          # Mock database connections or other services here
          DATABASE_URL: "mock://localhost:5432/testdb"

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: MyOrg
          password: ${{ secrets.DOCKER_HUB_TOKEN }}

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v4
        with:
          context: ./service-api
          file: ./service-api/Dockerfile
          push: true
          tags: myorg/service-api:${{ inputs.ref }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

这个工作流有几个关键点:

  • 它是一个 workflow_call,意味着它可以被其他工作流调用。
  • 它接收一个 ref 输入,用于检出指定版本的代码。
  • 我们使用了 actions/cache,并且 key 是基于 package-lock.json 的哈希值。这是提升效率的关键,只要依赖没有变化,npm ci 步骤就会非常快。
  • 我们将 Docker 层缓存也集成到了 GitHub Actions Cache 中 (cache-from: type=gha, cache-to: type=gha,mode=max),这能极大地加速镜像的重复构建。

我们为 Pinia 前端应用创建了一个类似的 reusable-web-ci.yml

第二步:编写变更分析脚本

这是整个方案的大脑。我们需要一个脚本来判断一次提交到底影响了哪些项目。我们将这个脚本也放在 devops-workflows 仓库中。

scripts/determine-changes.js:

// A simple Node.js script to determine changed projects

const { execSync } = require('child_process');
const fs = require('fs');

// Mapping of projects to their paths and dependencies
const PROJECT_CONFIG = {
  api: {
    path: 'service-api',
    // The 'api' project is affected if its own files change,
    // OR if files it depends on (like shared types) change.
    // For this example, we keep it simple.
    triggers: ['service-api/src', 'service-api/test', 'service-api/package.json', 'service-api/Dockerfile'],
  },
  web: {
    path: 'app-web',
    // The 'web' project is affected if its own files change,

    // OR if the API contract changes. THIS IS THE KEY!
    triggers: ['app-web/src', 'app-web/public', 'app-web/package.json', 'service-api/openapi.yaml'],
  },
};

function getChangedFiles(baseRef, headRef) {
  try {
    // git diff --name-only <base>...<head>
    // This command shows files that have changed between the two refs.
    const command = `git diff --name-only ${baseRef}...${headRef}`;
    const output = execSync(command).toString().trim();
    if (!output) {
      return [];
    }
    return output.split('\n');
  } catch (error) {
    console.error('Error getting changed files:', error);
    process.exit(1);
  }
}

function main() {
  const baseRef = process.env.BASE_REF;
  const headRef = process.env.HEAD_REF;

  if (!baseRef || !headRef) {
    console.error('BASE_REF and HEAD_REF environment variables are required.');
    process.exit(1);
  }

  const changedFiles = getChangedFiles(baseRef, headRef);
  console.log('Changed files:', changedFiles);

  const affectedProjects = new Set();

  for (const file of changedFiles) {
    for (const [project, config] of Object.entries(PROJECT_CONFIG)) {
      if (config.triggers.some(triggerPath => file.startsWith(triggerPath))) {
        affectedProjects.add(project);
      }
    }
  }
  
  const result = {
    // We create a matrix-compatible object for GitHub Actions
    include: Array.from(affectedProjects).map(p => ({ project: p }))
  };

  console.log('Affected projects:', JSON.stringify(result, null, 2));

  // Write the output to a file that GitHub Actions can read
  fs.writeFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(result)}`);
}

main();

这个脚本的核心逻辑是:

  1. 通过 git diff 获取两个提交点之间的所有变更文件列表。
  2. 定义一个 PROJECT_CONFIG,清晰地描述了每个项目(api, web)的路径以及会触发其构建的“触发器”路径列表。
  3. 最关键的一行是 'service-api/openapi.yaml' 被列为了 web 项目的触发器。这就以代码的形式明确了跨仓库的隐式依赖。
  4. 脚本最终输出一个 JSON 对象,其格式与 GitHub Actions 的 strategy.matrix 完全兼容。

第三步:创建中央调度工作流

现在,我们将所有部分组合在一起,创建最终的调度器工作流。这个文件位于任何一个代码仓库中(比如 service-api),或者最好是在一个专门的 .github 组织级仓库中。

.github/workflows/main-dispatcher.yml:

name: Main Dispatcher CI

on:
  push:
    branches:
      - main
      - 'release/**'
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  determine-changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.generate_matrix.outputs.matrix }}
    steps:
      - name: Checkout API Repository (for change analysis)
        uses: actions/checkout@v3
        with:
          repository: 'MyOrg/service-api'
          path: 'service-api'
          fetch-depth: 0 # Required to diff against base ref

      - name: Checkout Web Repository (for change analysis)
        uses: actions/checkout@v3
        with:
          repository: 'MyOrg/app-web'
          path: 'app-web'
          fetch-depth: 0

      - name: Checkout Workflows Repository
        uses: actions/checkout@v3
        with:
          repository: 'MyOrg/devops-workflows'
          path: 'devops-workflows'
      
      - name: Setup Node.js for script
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Generate execution matrix
        id: generate_matrix
        env:
          # For PRs, compare against the base branch. For pushes, compare against the previous commit.
          BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
          HEAD_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.event.after }}
        run: |
          node devops-workflows/scripts/determine-changes.js >> $GITHUB_OUTPUT

  trigger-downstream:
    needs: determine-changes
    # Only run this job if the matrix is not empty
    if: fromJson(needs.determine-changes.outputs.matrix).include[0] != null
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
    uses: MyOrg/devops-workflows/.github/workflows/reusable-${{ matrix.project }}-ci.yml@main
    with:
      ref: ${{ github.sha }}
    secrets:
      DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN_GLOBAL }}

这个调度器工作流是整个体系的核心:

  1. determine-changes job:
    • 它首先检出所有相关的代码仓库。fetch-depth: 0 很重要,它能确保我们有完整的 git 历史来进行 diff
    • 然后运行我们之前编写的 determine-changes.js 脚本。
    • 脚本的环境变量 BASE_REFHEAD_REF 会根据触发事件是 push 还是 pull_request 来智能地设置。
    • 脚本的输出(一个 JSON 字符串)被捕获并设置为 jobs.outputs
  2. trigger-downstream job:
    • needs: determine-changes,确保在拿到矩阵之后才运行。
    • if: fromJson(needs.determine-changes.outputs.matrix).include[0] != null 是一个保护措施,如果没有任何项目受影响,矩阵会是空的,这个 job 就会被跳过。
    • strategy.matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }} 是魔法发生的地方。它将上一个 job 输出的 JSON 字符串动态地转换成一个执行矩阵。如果脚本确定 apiweb 都受影响,这里就会生成两个并行的 job。
    • uses: MyOrg/devops-workflows/.github/workflows/reusable-${{ matrix.project }}-ci.yml@main 动态地调用了正确的、可复用的工作流。matrix.project 会被替换为 apiweb

最终成果:一个可观测且高效的 CI 系统

现在,当一个开发者提交代码时:

  • 场景1:修改 app-web/src/components/Button.vue
    • determine-changes 脚本分析出只有 web 项目受影响。
    • 矩阵生成为 { "include": [{ "project": "web" }] }
    • 只有一个 trigger-downstream job 会被启动,调用 reusable-web-ci.yml。后端 CI 保持静默。
  • 场景2:修改 service-api/openapi.yaml
    • determine-changes 脚本根据我们的规则,分析出 web 项目受此影响。
    • 矩阵再次生成为 { "include": [{ "project": "web" }] }
    • 前端 CI 被正确触发,而后端 CI 因为源码未变,依然保持静默。
  • 场景3:同时修改后端路由和前端页面
    • 脚本分析出 apiweb 都受影响。
    • 矩阵生成为 { "include": [{ "project": "api" }, { "project": "web" }] }
    • 两个 trigger-downstream job 会被并行启动,一个用于 API,一个用于 Web,最大限度地利用了计算资源并缩短了反馈时间。

我们可以用 Mermaid.js 来可视化这个决策流程:

graph TD
    A[Push or PR Trigger] --> B{Checkout All Repos & Run Change-Analysis Script};
    B --> C{Generate Matrix JSON};
    C --> D{Matrix Empty?};
    D -- Yes --> E[End Workflow];
    D -- No --> F[Start Parallel Jobs Based on Matrix];
    F --> G1[Run API CI Job];
    F --> G2[Run Web CI Job];
    subgraph "Example Matrix: [api, web]"
        G1
        G2
    end

方案的局限性与未来迭代方向

尽管这个方案极大地提升了我们的 CI 效率和准确性,但它并非银弹。在真实项目中,这个架构也存在一些需要注意的权衡和局限。

首先,determine-changes.js 这个变更分析脚本本身变成了需要维护的关键基础设施代码。随着项目复杂度增加,项目间的依赖关系可能会变得非常复杂,这个脚本的维护成本也会相应提高。

其次,当仓库数量从两三个增长到几十上百个时,在调度器中检出所有仓库会变得非常缓慢和低效。这种集中式调度方案更适合中等规模的 Polyrepo 集群。对于超大规模的场景,可能需要考虑使用 Bazel 或类似的构建系统,即使在 Polyrepo 中,也能通过一些高级技巧来管理依赖图。

最后,当前的脚本只做了项目级别的变更检测。未来的一个优化方向是实现更细粒度的控制,例如与代码覆盖率工具结合。如果一次后端提交只影响了订单模块,我们或许可以只运行与订单相关的集成测试,而不是全部测试套件。这需要更深度的代码静态分析,是进一步压榨 CI 效率的下一个前沿。


  目录