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
|19. März 20268 Min. Lesezeit1.8k Wörter|

GitHub-Aktionen CI/CD für Monorepo-Projekte

Eine CI/CD-Pipeline für ein Monorepo steht vor einer grundlegend anderen Herausforderung als eine Single-Application-Pipeline: Wie können Sie schnelle und zuverlässige Pipelines betreiben, wenn sich nur eine Ihrer fünf Anwendungen geändert hat? Wenn Sie naiv alles auf jedem Commit aufbauen, verwandelt sich eine 2-Minuten-Pipeline in einen 15-Minuten-Engpass, den Entwickler durch Stapeln von Commits umgehen – genau das, was Sie verhindern wollen.

In diesem Leitfaden wird ein Produktions-GitHub-Aktions-Setup für ein Turborepo-Monorepo behandelt: nur betroffene Builds, parallele Jobausführung, Turbo-Remote-Caching-Integration, umgebungsbasierte Bereitstellungs-Gates und die Sicherheitsmuster, die verhindern, dass Geheimnisse in Protokolle oder gespaltene PR-Läufe gelangen.

Wichtige Erkenntnisse

  • Verwenden Sie fetch-depth: 2 beim Bezahlen, um die Erkennung betroffener Pakete durch Turbo zu aktivieren
  • Führen Sie Lint-, Typprüfungs-, Test- und Build-Jobs parallel aus – verketten Sie sie nicht nacheinander – Mit Matrix-Jobs können Sie mehrere Node.js-Versionen oder Betriebssysteme testen, ohne YAML zu duplizieren – Speichern Sie Turbo-Remote-Cache-Anmeldeinformationen als GitHub-Geheimnisse und nicht als Umgebungsvariablen in YAML – Verwenden Sie environment:-Schutzregeln für Produktionsbereitstellungsjobs, um eine manuelle Genehmigung zu erfordern – Mit der Aktion paths-filter können Sie CI vollständig überspringen, wenn nur Dokumente oder Nicht-Code-Dateien geändert wurden – Geben Sie niemals Geheimnisse in Protokollen aus – verwenden Sie ::add-mask:: für zur Laufzeit generierte Geheimnisse – Wiederverwendbare Arbeitsabläufe (workflow_call) verhindern Duplikate über mehrere Repository-Arbeitsabläufe hinweg

Repository-Struktur für CI

Ein gutes CI-Setup spiegelt Ihren Entwicklungsworkflow wider. Hier ist die Struktur, die für ein Turborepo-Monorepo funktioniert:

.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

Kern-CI-Workflow

# .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

E2E-Testjob mit Dramatiker

  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

Sicherheitsscan-Workflow

# .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 }}

Bereitstellungsworkflow

# .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 }}

Caching-Strategie

Effektives Caching ist der Unterschied zwischen 2-minütigen und 8-minütigen CI-Läufen:

# 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

Best Practices für die Sicherheit

Niemals Geheimnisse wiedergeben:

# 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

Berechtigungen einschränken:

# 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

Pull-Request-Injection verhindern:

# 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

Häufig gestellte Fragen

Wie löse ich einen Workflow nur aus, wenn sich bestimmte Pakete ändern?

Verwenden Sie dorny/paths-filter, um zu erkennen, welche Pakete geändert wurden, und übergeben Sie dann die Filterausgaben an Jobbedingungen. Stellen Sie für die integrierte Betroffenenerkennung von Turbo sicher, dass Sie in Ihrem Checkout-Schritt fetch-depth: 2 angeben, damit Turbo einen Vergleich mit dem vorherigen Commit durchführen kann. Verwenden Sie pnpm turbo run test --filter="...[HEAD^1]", um Tests nur für geänderte Pakete und deren abhängige Pakete auszuführen.

Wie soll ich Datenbankmigrationen in CI handhaben?

Führen Sie Migrationen in einem separaten Job aus, der nach den Tests, aber vor der Erstellung des endgültigen Build-Artefakts ausgeführt wird. Verwenden Sie eine dedizierte Testdatenbank-URL in CI (getrennt von Entwicklung und Produktion). Führen Sie Migrationen gegen die Produktion niemals automatisch von CI aus – verwenden Sie workflow_dispatch mit manueller Genehmigung über GitHub-Umgebungsschutzregeln für Änderungen an Produktionsdatenbanken.

Wie lassen sich Geheimnisse am besten über mehrere Umgebungen hinweg verwalten?

Verwenden Sie GitHub-Umgebungen (Einstellungen > Umgebungen) mit Schutzregeln. Erstellen Sie separate Umgebungen für staging und production. Speichern Sie umgebungsspezifische Geheimnisse in jeder Umgebung, nicht auf Repository-Ebene. Verwenden Sie für gemeinsame Geheimnisse (wie das Turbo-Remote-Cache-Token) Geheimnisse auf Repository-Ebene. Der Schlüssel environment: in der Jobkonfiguration ermöglicht umgebungsspezifische Geheimnisse und Schutzregeln.

Wie beschleunige ich die PNPM-Installation in CI?

Der actions/setup-node@v4 mit cache: 'pnpm' speichert den PNPM-Speicher zwischen. Verwenden Sie bei großen Monorepos auch pnpm install --prefer-offline, nachdem Sie den Cache wiederhergestellt haben. Die Kombination aus pnpm-Store-Caching und Turbo-Remote-Caching reduziert die Installations- und Erstellungszeit bei Cache-Treffern normalerweise um 60–80 %.

Wie führe ich unterschiedliche Workflows für PRs im Vergleich zu Hauptzweig-Pushes aus?

Verwenden Sie die on-Triggerbedingungen: on.push.branches für Haupt- und on.pull_request.branches für PRs. Sie können eine schnellere Teilmenge von Tests für PRs (Lint, Unit-Tests) und die gesamte Suite auf main ausführen. Verwenden Sie github.event_name == 'push'-Bedingungen, um Bereitstellungsjobs nur beim Push an den Hauptserver und nicht bei PR-Ereignissen auszuführen.


Nächste Schritte

Eine gut konzipierte CI/CD-Pipeline ist die Grundlage eines Hochgeschwindigkeits-Engineering-Teams – sie gibt Ihnen die Sicherheit, häufig zu versenden und Regressionen automatisch zu erkennen. ECOSIRE führt GitHub Actions CI mit Lint, Typprüfung, 1.301 Unit-Tests, Playwright E2E und SSH-Bereitstellungen ohne Ausfallzeiten bei jedem Push auf Main aus.

Ganz gleich, ob Sie Beratung zur CI/CD-Architektur, die Einrichtung eines Turborepo-Monorepo oder vollständigen DevOps-Support benötigen, entdecken Sie unsere Engineering-Services.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp