我们的团队维护着一套典型的 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 的 paths
和 paths-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 的决策逻辑被分散在各个仓库中,每个工作流都只“看得到”自己仓库内的变更。为了解决这个问题,我们需要一个“上帝视角”的协调者,它能感知到所有相关仓库的变更,并据此动态地决定需要执行哪些任务。
我们决定采用一种集中式的调度模式:
- 创建一个独立的、专门用于存放 CI/CD 逻辑的仓库,例如
devops-workflows
。 - 使用 GitHub Actions 的可复用工作流 (Reusable Workflows) 功能。
- 主工作流(调度器)将被所有代码仓库的变更触发。
- 调度器首先拉取所有相关仓库的代码,然后通过
git
命令分析本次提交真正影响了哪些项目(api
,web
, 或两者)。 - 基于分析结果,动态生成一个执行矩阵(
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();
这个脚本的核心逻辑是:
- 通过
git diff
获取两个提交点之间的所有变更文件列表。 - 定义一个
PROJECT_CONFIG
,清晰地描述了每个项目(api
,web
)的路径以及会触发其构建的“触发器”路径列表。 - 最关键的一行是
'service-api/openapi.yaml'
被列为了web
项目的触发器。这就以代码的形式明确了跨仓库的隐式依赖。 - 脚本最终输出一个 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 }}
这个调度器工作流是整个体系的核心:
determine-changes
job:- 它首先检出所有相关的代码仓库。
fetch-depth: 0
很重要,它能确保我们有完整的 git 历史来进行diff
。 - 然后运行我们之前编写的
determine-changes.js
脚本。 - 脚本的环境变量
BASE_REF
和HEAD_REF
会根据触发事件是push
还是pull_request
来智能地设置。 - 脚本的输出(一个 JSON 字符串)被捕获并设置为
jobs.outputs
。
- 它首先检出所有相关的代码仓库。
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 字符串动态地转换成一个执行矩阵。如果脚本确定api
和web
都受影响,这里就会生成两个并行的 job。 -
uses: MyOrg/devops-workflows/.github/workflows/reusable-${{ matrix.project }}-ci.yml@main
动态地调用了正确的、可复用的工作流。matrix.project
会被替换为api
或web
。
- 它
最终成果:一个可观测且高效的 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:同时修改后端路由和前端页面
- 脚本分析出
api
和web
都受影响。 - 矩阵生成为
{ "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 效率的下一个前沿。