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 de marzo de 20269 min de lectura1.9k Palabras|

GitHub Actions CI/CD para proyectos Monorepo

Una canalización de CI/CD para un monorepo enfrenta un desafío fundamentalmente diferente al de una canalización de una sola aplicación: ¿cómo se ejecutan canalizaciones rápidas y confiables cuando solo cambió 1 de sus 5 aplicaciones? Construir ingenuamente todo en cada confirmación convierte una canalización de 2 minutos en un cuello de botella de 15 minutos que los desarrolladores solucionan agrupando confirmaciones por lotes, exactamente lo que estás tratando de evitar.

Esta guía cubre una configuración de producción de GitHub Actions para un monorepo de Turborepo: compilaciones solo afectadas, ejecución de trabajos paralelos, integración de almacenamiento en caché remoto de Turbo, puertas de implementación basadas en el entorno y patrones de seguridad que evitan que los secretos se filtren en registros o ejecuciones de relaciones públicas bifurcadas.

Conclusiones clave

  • Utilice fetch-depth: 2 al finalizar la compra para habilitar la detección de paquetes afectados de Turbo
  • Ejecute lint, verifique tipos, pruebe y cree trabajos en paralelo; no los encadene secuencialmente
  • Los trabajos Matrix le permiten realizar pruebas en múltiples versiones o sistemas operativos de Node.js sin duplicar YAML.
  • Almacene las credenciales de caché remota de Turbo como GitHub Secrets, no como variables de entorno en YAML
  • Utilice reglas de protección environment: en trabajos de implementación de producción para requerir aprobación manual
  • La acción paths-filter le permite omitir CI por completo cuando solo se cambian documentos o archivos sin código
  • Nunca imprima secretos en registros; use ::add-mask:: para secretos generados en tiempo de ejecución
  • Los flujos de trabajo reutilizables (workflow_call) eliminan la duplicación en múltiples flujos de trabajo del repositorio.

Estructura del repositorio para CI

Una buena configuración de CI refleja su flujo de trabajo de desarrollo. Aquí está la estructura que funciona para un monorepo Turborepo:

.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

Flujo de trabajo de CI central

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

Trabajo de prueba E2E con dramaturgo

  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

Flujo de trabajo de escaneo de seguridad

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

Flujo de trabajo de implementación

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

Estrategia de almacenamiento en caché

El almacenamiento en caché efectivo es la diferencia entre ejecuciones de CI de 2 y 8 minutos:

# 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

Mejores prácticas de seguridad

Nunca hagas eco de secretos:

# 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

Restringir permisos:

# 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

Evitar la inyección de solicitud de extracción:

# 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

Preguntas frecuentes

¿Cómo activo un flujo de trabajo solo cuando cambian paquetes específicos?

Utilice dorny/paths-filter para detectar qué paquetes cambiaron y luego pase las salidas del filtro a las condiciones del trabajo. Para la detección de afectados incorporada de Turbo, asegúrese de fetch-depth: 2 en su paso de pago para que Turbo pueda compararse con la confirmación anterior. Utilice pnpm turbo run test --filter="...[HEAD^1]" para ejecutar pruebas solo para paquetes modificados y sus dependientes.

¿Cómo debo manejar las migraciones de bases de datos en CI?

Ejecute las migraciones en un trabajo independiente que se ejecute después de las pruebas pero antes de que se cree el artefacto de compilación final. Utilice una URL de base de datos de prueba dedicada en CI (separada de desarrollo y producción). Nunca ejecute migraciones contra producción desde CI automáticamente: use workflow_dispatch con aprobación manual a través de las reglas de protección del entorno de GitHub para cambios en la base de datos de producción.

¿Cuál es la mejor manera de administrar secretos en múltiples entornos?

Utilice entornos de GitHub (Configuración > Entornos) con reglas de protección. Cree entornos separados para staging y production. Almacene secretos específicos del entorno en cada entorno, no a nivel de repositorio. Para secretos compartidos (como el token de caché remoto Turbo), utilice secretos a nivel de repositorio. La clave environment: en la configuración del trabajo habilita secretos y reglas de protección específicos del entorno.

¿Cómo acelero la instalación de pnpm en CI?

El actions/setup-node@v4 con cache: 'pnpm' almacena en caché el almacén pnpm. Para monorepos grandes, use también pnpm install --prefer-offline después de restaurar el caché. La combinación del almacenamiento en caché del almacén pnpm y el almacenamiento en caché remoto Turbo generalmente reduce el tiempo de instalación y compilación entre un 60 % y un 80 % en los accesos al caché.

¿Cómo ejecuto diferentes flujos de trabajo para relaciones públicas y para envíos de rama principal?

Utilice las condiciones de activación on: on.push.branches para principal y on.pull_request.branches para PR. Puede ejecutar un subconjunto de pruebas más rápido para PR (lint, pruebas unitarias) y el conjunto completo en main. Utilice condiciones github.event_name == 'push' para ejecutar trabajos de implementación solo al enviar a principal, no en eventos de relaciones públicas.


Próximos pasos

Una canalización de CI/CD bien diseñada es la base de un equipo de ingeniería de alta velocidad: le brinda confianza para realizar envíos frecuentes y detectar regresiones automáticamente. ECOSIRE ejecuta GitHub Actions CI con lint, verificación de tipos, 1301 pruebas unitarias, Playwright E2E e implementaciones SSH sin tiempo de inactividad en cada envío a main.

Ya sea que necesite consultoría de arquitectura CI/CD, configuración de Turborepo monorepo o soporte completo de DevOps, explore nuestros servicios de ingeniería.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp