Monorepo 项目的 GitHub Actions CI/CD
monorepo 的 CI/CD 管道面临着与单一应用程序管道完全不同的挑战:当 5 个应用程序中只有 1 个发生更改时,如何运行快速、可靠的管道?天真地在每次提交上构建所有内容会将 2 分钟的管道变成 15 分钟的瓶颈,开发人员可以通过批量提交来解决这个问题 - 这正是您想要防止的。
本指南介绍了 Turborepo monorepo 的生产 GitHub Actions 设置:仅受影响的构建、并行作业执行、Turbo 远程缓存集成、基于环境的部署门以及防止机密泄漏到日志或分叉 PR 运行中的安全模式。
要点
- 在结账时使用
fetch-depth: 2来启用 Turbo 受影响的包裹检测- 并行运行 lint、类型检查、测试和构建 — 不要按顺序链接它们
- 矩阵作业让您可以跨多个 Node.js 版本或操作系统进行测试,而无需重复 YAML
- 将 Turbo 远程缓存凭证存储为 GitHub Secrets,而不是 YAML 中的环境变量
- 对生产部署作业使用
environment:保护规则以要求手动批准paths-filter操作可让您在仅更改文档或非代码文件时完全跳过 CI- 永远不要将机密打印到日志中 - 使用
::add-mask::作为运行时生成的机密- 可重用工作流程 (
workflow_call) 消除多个存储库工作流程中的重复
CI 的存储库结构
良好的 CI 设置反映了您的开发工作流程。以下是适用于 Turborepo monorepo 的结构:
.github/
workflows/
ci.yml — Runs on every PR and push to main
deploy.yml — Runs on push to main (after CI passes)
security.yml — Weekly security scans
cleanup.yml — Scheduled: delete stale preview deployments
actions/
setup-pnpm/
action.yml — Reusable: install Node + pnpm + cache
核心 CI 工作流程
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Cancel in-progress runs when a new run starts
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
NODE_VERSION: '22'
PNPM_VERSION: '10.28.0'
jobs:
# Fast path: skip if only docs/assets changed
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'apps/**/*.ts'
- 'apps/**/*.tsx'
- 'packages/**/*.ts'
- 'turbo.json'
- 'package.json'
- 'pnpm-lock.yaml'
docs:
- 'apps/docs/**'
- '**/*.md'
lint:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Required for turbo affected detection
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm turbo run lint
- name: Type check
run: pnpm turbo run type-check
test:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: ecosire_test
POSTGRES_USER: ecosire
POSTGRES_PASSWORD: password
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm turbo run test
env:
DATABASE_URL: postgresql://ecosire:password@localhost:5433/ecosire_test
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./apps/api/coverage/lcov.info
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build all apps
run: pnpm turbo run build
env:
NEXT_PUBLIC_API_URL: ${{ vars.STAGING_API_URL }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ github.sha }}
path: |
apps/web/.next/
apps/api/dist/
retention-days: 1 # Keep briefly for deploy job
与 Playwright 进行 E2E 测试工作
e2e:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
project: [chromium, firefox, mobile-chrome]
fail-fast: false # Don't cancel other browsers if one fails
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd apps/web && npx playwright install ${{ matrix.project }} --with-deps
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-${{ github.sha }}
- name: Start test servers
run: |
cd apps/api && node dist/main.js &
cd apps/web && npx next start &
npx wait-on http://localhost:3000 http://localhost:3001/health
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Run E2E tests
run: cd apps/web && npx playwright test --project=${{ matrix.project }}
env:
BASE_URL: http://localhost:3000
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.project }}
path: apps/web/playwright-report/
retention-days: 7
安全扫描工作流程
# .github/workflows/security.yml
name: Security Scan
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 2am UTC
push:
paths:
- '**/package.json'
- 'pnpm-lock.yaml'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- name: Run pnpm audit
run: pnpm audit --audit-level moderate
continue-on-error: true # Don't fail CI, just report
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
command: test
args: --severity-threshold=high
secret-scanning:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for secret scanning
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
部署工作流程
# .github/workflows/deploy.yml
name: Deploy to Production
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
# Only one production deploy at a time
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false # Don't cancel in-flight deploys
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run quality checks
run: |
pnpm turbo run lint type-check test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
deploy:
needs: quality-gate
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # Requires approval for production
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /opt/ecosire/app
echo "Pulling latest changes..."
git pull origin main
echo "Installing dependencies..."
pnpm install --frozen-lockfile
echo "Building..."
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} \
TURBO_TEAM=${{ vars.TURBO_TEAM }} \
npx turbo run build
echo "Running migrations..."
pnpm --filter @ecosire/db db:migrate
echo "Restarting services..."
pm2 restart ecosystem.config.cjs --update-env
echo "Health check..."
sleep 10
curl -f https://ecosire.com/api/health || exit 1
curl -f https://api.ecosire.com/api/health || exit 1
echo "Deploy complete!"
- name: Notify on success
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.repos.createCommitComment({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha,
body: `Deployed to ${process.env.ENVIRONMENT} at ${new Date().toISOString()}`
})
env:
ENVIRONMENT: ${{ inputs.environment }}
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Deploy failed: ${process.env.ENVIRONMENT}`,
body: `Deploy to ${process.env.ENVIRONMENT} failed at ${new Date().toISOString()}\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
})
env:
ENVIRONMENT: ${{ inputs.environment }}
缓存策略
有效缓存是 2 分钟和 8 分钟 CI 运行之间的差异:
# Reusable setup step (extract to composite action)
# .github/actions/setup-pnpm/action.yml
name: Setup pnpm
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
# Turbo cache (separate from pnpm cache)
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
安全最佳实践
永远不要回显秘密:
# Wrong — secret appears in logs
- run: echo "API key is ${{ secrets.API_KEY }}"
# If you must generate and use runtime secrets
- run: |
echo "::add-mask::$GENERATED_SECRET"
GENERATED_SECRET=$(generate-secret)
echo "GENERATED_SECRET=$GENERATED_SECRET" >> $GITHUB_ENV
限制权限:
# Minimum permissions at workflow level
permissions:
contents: read
actions: read
checks: write # Add only what you need
jobs:
test:
permissions:
contents: read # Override at job level if needed
防止拉取请求注入:
# For PRs from forks — never expose secrets
on:
pull_request:
jobs:
test:
# Secrets not available in fork PRs by default
# Use pull_request_target only for trusted actions
runs-on: ubuntu-latest
常见问题
如何仅在特定包发生更改时触发工作流程?
使用 dorny/paths-filter 检测哪些包发生了更改,然后将过滤器输出传递给作业条件。对于 Turbo 内置的受影响检测,请确保结账步骤中包含 fetch-depth: 2 ,以便 Turbo 可以与之前的提交进行比较。使用 pnpm turbo run test --filter="...[HEAD^1]" 仅对更改的包及其依赖项运行测试。
如何在 CI 中处理数据库迁移?
在测试之后但创建最终构建工件之前运行的单独作业中运行迁移。在 CI 中使用专用的测试数据库 URL(与开发和生产分开)。切勿自动从 CI 运行针对生产的迁移 — 使用 workflow_dispatch 并通过 GitHub 环境保护规则手动批准生产数据库更改。
跨多个环境管理机密的最佳方法是什么?
将 GitHub 环境(设置 > 环境)与保护规则结合使用。为 staging 和 production 创建单独的环境。在每个环境下存储特定于环境的机密,而不是存储在存储库级别。对于共享机密(例如 Turbo 远程缓存令牌),请使用存储库级机密。作业配置中的 environment: 键启用特定于环境的秘密和保护规则。
如何加快 CI 中的 pnpm 安装速度?
actions/setup-node@v4 和 cache: 'pnpm' 缓存 pnpm 存储。对于大型 monorepos,恢复缓存后也使用 pnpm install --prefer-offline 。 pnpm 存储缓存和 Turbo 远程缓存的组合通常可以将缓存命中的安装 + 构建时间缩短 60-80%。
如何为 PR 与主分支推送运行不同的工作流程?
使用 on 触发条件:on.push.branches 用于 main,on.pull_request.branches 用于 PR。您可以在 main 上运行更快的 PR 测试子集(lint、单元测试)和完整套件。使用 github.event_name == 'push' 条件仅在推送到主程序时运行部署作业,而不是在 PR 事件上运行。
后续步骤
精心设计的 CI/CD 管道是高速工程团队的基础 - 它让您有信心频繁交付并自动捕获回归。 ECOSIRE 运行 GitHub Actions CI,包括 lint、类型检查、1,301 单元测试、Playwright E2E 以及每次推送到主程序时的零停机 SSH 部署。
无论您需要 CI/CD 架构咨询、Turborepo monorepo 设置还是完整的 DevOps 支持,探索我们的工程服务。
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.