GitHub Actions CI/CD for Monorepo Projects

Complete GitHub Actions CI/CD guide for Turborepo monorepos: affected-only builds, parallel jobs, caching strategies, environment-based deploys, and security best practices.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分钟阅读1.3k 字数|

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 环境(设置 > 环境)与保护规则结合使用。为 stagingproduction 创建单独的环境。在每个环境下存储特定于环境的机密,而不是存储在存储库级别。对于共享机密(例如 Turbo 远程缓存令牌),请使用存储库级机密。作业配置中的 environment: 键启用特定于环境的秘密和保护规则。

如何加快 CI 中的 pnpm 安装速度?

actions/setup-node@v4cache: '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 支持,探索我们的工程服务

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天