Módulo 6: Proyecto — Pipeline CI/CD Completo con Claude Code

Implementación: Stages 1-3 (PR Review)

Implementación: Stages 1-3 (PR Review)

Descripción

Esta cápsula es donde empiezas a construir el pipeline. Tomas el diseño de la cápsula 02 y implementas la Fase 1: los 3 stages de PR review que corren en paralelo en cada pull request — code review automático, security scan, y tests + linting.

Esta es la fase más visible del pipeline para el equipo: cada developer la va a ver en cada PR. Vale la pena hacerla bien — reusable, rápida, con feedback útil. Vas a integrar las técnicas de los Módulos 1, 2 y 5 en un único workflow coherente, evitando duplicación y aprovechando paralelismo.

Al terminar, vas a tener la primera fase del pipeline funcionando end-to-end en un repositorio de prueba — un cimiento sólido para las fases 2-4.


La Estructura del Workflow

# .github/workflows/pr-review.yml
name: PR Review (Phase 1)

on:
  pull_request:
    types: [opened, synchronize, ready_for_review]
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'requirements.txt'

permissions:
  contents: read
  pull-requests: write
  security-events: write

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  # Job común: extraer diff (output usado por los demás)
  extract-diff:
    runs-on: ubuntu-latest
    if: github.event.pull_request.draft == false
    outputs:
      diff_size: ${{ steps.extract.outputs.diff_size }}
      should_review: ${{ steps.extract.outputs.should_review }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      
      - name: Extract filtered diff
        id: extract
        run: |
          git diff origin/${{ github.base_ref }}...HEAD \
            --diff-filter=ACMR \
            -- '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.go' '*.rb' \
            > pr_diff.txt
          
          SIZE=$(wc -l < pr_diff.txt)
          echo "diff_size=$SIZE" >> $GITHUB_OUTPUT
          
          if [ "$SIZE" -eq 0 ]; then
            echo "should_review=false" >> $GITHUB_OUTPUT
          elif [ "$SIZE" -gt 2000 ]; then
            echo "should_review=large" >> $GITHUB_OUTPUT
          else
            echo "should_review=true" >> $GITHUB_OUTPUT
          fi
      
      - uses: actions/upload-artifact@v4
        with:
          name: pr-diff
          path: pr_diff.txt
          retention-days: 1

  # ========================================
  # Stages paralelos
  # ========================================
  
  code-review:
    needs: extract-diff
    if: needs.extract-diff.outputs.should_review == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install anthropic requests
      - uses: actions/download-artifact@v4
        with: { name: pr-diff }
      
      - name: Run code review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          CLAUDE_MODEL: 'claude-haiku-4-5'
        run: python scripts/code_review.py

  security-scan:
    needs: extract-diff
    if: needs.extract-diff.outputs.should_review == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install anthropic requests
      - uses: actions/download-artifact@v4
        with: { name: pr-diff }
      
      - name: Run security scan
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CLAUDE_MODEL: 'claude-sonnet-5'
        run: python scripts/security_scan.py

  tests-and-linting:
    needs: extract-diff
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      
      - run: pip install -r requirements.txt
      
      - name: Run linting
        run: |
          pip install ruff
          ruff check .
      
      - name: Run tests
        run: pytest --cov=src --cov-report=xml
      
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  # Job de rollup que reporta el estado consolidado
  pr-review-summary:
    needs: [code-review, security-scan, tests-and-linting]
    if: always()  # corre incluso si jobs anteriores fallan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install requests
      
      - name: Post consolidated summary
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CODE_REVIEW_STATUS: ${{ needs.code-review.result }}
          SECURITY_SCAN_STATUS: ${{ needs.security-scan.result }}
          TESTS_STATUS: ${{ needs.tests-and-linting.result }}
        run: python scripts/post_consolidated_summary.py

Lo importante de la estructura:

  • extract-diff corre primero (rápido, output usado por todos)
  • Los 3 stages corren en paralelo (independientes entre sí)
  • pr-review-summary consolida al final, incluso si algunos fallan
  • concurrency cancela runs viejos del mismo PR (cápsula 05 M1)

El Job de Extracción del Diff

"""scripts/extract_pr_files.py — usado por extract-diff job"""
# Si el bash inline no es suficiente, puedes moverlo a un script Python:

import json
import os
import subprocess
import sys


RELEVANT_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}


def main() -> int:
    base_ref = os.environ["GITHUB_BASE_REF"]
    
    # Asegurarse de tener el remote actualizado
    subprocess.run(["git", "fetch", "origin", base_ref], check=True)
    
    # Generar diff filtrado
    result = subprocess.run(
        ["git", "diff", f"origin/{base_ref}...HEAD",
         "--diff-filter=ACMR",
         "--", "*.py", "*.ts", "*.tsx", "*.js", "*.jsx", "*.go", "*.rb"],
        capture_output=True, text=True, check=True,
    )
    
    diff = result.stdout
    
    # Guardar
    with open("pr_diff.txt", "w") as f:
        f.write(diff)
    
    # Stats
    diff_lines = len(diff.splitlines())
    
    # Decidir si vale la pena revisar
    if diff_lines == 0:
        should_review = "false"
    elif diff_lines > 2000:
        should_review = "large"  # señal para chunking más agresivo
    else:
        should_review = "true"
    
    # Output para el siguiente step
    with open(os.environ.get("GITHUB_OUTPUT", "/dev/stdout"), "a") as f:
        f.write(f"diff_size={diff_lines}\n")
        f.write(f"should_review={should_review}\n")
    
    print(f"Diff size: {diff_lines} lines")
    print(f"Should review: {should_review}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Code Review Job (Reusing Module 2)

El script ya lo construiste en el Módulo 2 cápsula 03. Acá lo reusas como está:

# scripts/code_review.py
# Script del Módulo 2 cápsula 03 — sin cambios.
# Toma pr_diff.txt como input, postea inline comments al PR.

Punto clave: no reescribir el código de Módulo 2. Reusarlo. Si en el M2 cápsula 03 implementaste correctamente, este job es plug-and-play.


Security Scan Job (Reusing Module 5)

Similar al code review, el script viene del Módulo 5 cápsula 02:

# scripts/security_scan.py
# Script del Módulo 5 cápsula 02 — sin cambios.
# Detecta vulnerabilidades, postea findings al PR.

Diferencias clave entre code review y security scan:

CODE REVIEW (M2):
- Modelo: Haiku (más económico)
- Operación: suggest-only por default
- Output: PR comment + inline comments
- Failure behavior: warn (no bloquea)

SECURITY SCAN (M5):
- Modelo: Sonnet (más calidad)
- Operación: bloquea en critical findings
- Output: PR comment estructurado
- Failure behavior: block on critical

Cada uno con su prompt, modelo, y política — pero en el mismo workflow paralelo.


Tests and Linting Job

tests-and-linting:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: '3.11'
        cache: 'pip'
    
    - run: pip install -r requirements.txt
    
    - name: Linting (ruff)
      run: |
        pip install ruff
        ruff check . --output-format=github
    
    - name: Type checking (mypy, opcional)
      continue-on-error: true  # warning, no block
      run: |
        pip install mypy
        mypy src/
    
    - name: Unit tests
      run: pytest tests/ --cov=src --cov-report=xml --cov-fail-under=70
    
    - name: Upload coverage
      if: always()
      uses: codecov/codecov-action@v4
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: ./coverage.xml

Notas:

  • ruff check . --output-format=github produce annotations nativas (warnings inline en el PR)
  • mypy con continue-on-error: true para warning sin block
  • --cov-fail-under=70 requiere coverage mínimo de 70%

El Summary Consolidado

El job final del workflow es rollup de status — toma los resultados de los 3 jobs paralelos y postea un resumen claro al PR:

"""scripts/post_consolidated_summary.py

Postea un comment unificado al PR resumiendo los 3 jobs.
"""
import os
import sys
import requests


MARKER = "<!-- pr-review-phase-1-summary -->"


def status_emoji(status: str) -> str:
    return {
        "success": "✅",
        "failure": "❌",
        "cancelled": "🚫",
        "skipped": "⏭️",
    }.get(status, "❓")


def status_label(status: str) -> str:
    return {
        "success": "PASS",
        "failure": "FAIL",
        "cancelled": "CANCELLED",
        "skipped": "SKIPPED",
    }.get(status, "UNKNOWN")


def build_summary() -> str:
    code_review = os.environ.get("CODE_REVIEW_STATUS", "skipped")
    security = os.environ.get("SECURITY_SCAN_STATUS", "skipped")
    tests = os.environ.get("TESTS_STATUS", "skipped")
    
    overall_pass = all(s == "success" for s in [code_review, security, tests])
    
    return f"""{MARKER}

# 🤖 PR Review Summary (Phase 1)

| Check | Status |
|-------|--------|
| Code Review | {status_emoji(code_review)} {status_label(code_review)} |
| Security Scan | {status_emoji(security)} {status_label(security)} |
| Tests + Linting | {status_emoji(tests)} {status_label(tests)} |

{'✅ **Todos los checks pasaron — PR listo para review humano.**' if overall_pass
else '⚠️  **Hay checks que requieren atención antes del merge.**'}

---

### Detalles
- **Code review:** ver comments inline arriba (con marker `claude-code-bot`)
- **Security scan:** ver comment con marker `security-scan-bot`
- **Tests + linting:** ver detalle en el [workflow run]({get_run_url()})

*Resumen generado automáticamente.*
"""


def get_run_url() -> str:
    repo = os.environ.get("GITHUB_REPOSITORY", "")
    run_id = os.environ.get("GITHUB_RUN_ID", "")
    return f"https://github.com/{repo}/actions/runs/{run_id}"


def find_existing_summary(comments: list) -> dict | None:
    for c in comments:
        if MARKER in c.get("body", ""):
            return c
    return None


def main() -> int:
    repo = os.environ["GITHUB_REPOSITORY"]
    pr_number = os.environ["PR_NUMBER"]
    token = os.environ["GITHUB_TOKEN"]
    
    body = build_summary()
    
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }
    
    list_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
    existing = requests.get(list_url, headers=headers).json()
    summary_comment = find_existing_summary(existing)
    
    if summary_comment:
        # Update
        update_url = f"https://api.github.com/repos/{repo}/issues/comments/{summary_comment['id']}"
        r = requests.patch(update_url, headers=headers, json={"body": body})
    else:
        # Create
        r = requests.post(list_url, headers=headers, json={"body": body})
    
    if r.status_code in [200, 201]:
        print("Consolidated summary posted")
        return 0
    
    print(f"ERROR: {r.status_code} {r.text}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Resultado en el PR: un único comment al inicio del PR con el resumen de los 3 jobs. Cada job puede tener su propio comment detallado, pero el summary es la vista panorámica.


Optimizaciones Importantes

1. Cache de pip

Cada job instala dependencias. Sin cache, son ~30s por job × 3 = ~90s desperdiciados.

- uses: actions/setup-python@v5
  with:
    python-version: '3.11'
    cache: 'pip'  # ← cache automático

Reduce a ~5s por job.

2. Concurrency: cancelar runs viejos

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

Si el developer hace 3 pushes seguidos al PR, solo el último completa los 3 jobs. Los anteriores se cancelan.

3. Filtrado por path

El workflow solo dispara si el PR toca código fuente (no docs, configs):

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'requirements.txt'

Cambios solo a docs no necesitan code review automático.

4. Skipear PRs en draft

extract-diff:
  if: github.event.pull_request.draft == false

Ahorra tokens en trabajo en progreso.

5. Skipear PRs sin diff relevante

El job extract-diff ya output should_review. Los demás jobs lo respetan:

code-review:
  if: needs.extract-diff.outputs.should_review == 'true'

Si el PR solo cambia archivos no relevantes, code review skipea.


Trampas Comunes

1. Stages dependiendo de un mismo job sin necesidad

Síntoma: Code review espera a security scan aunque son independientes.

Por qué pasa: Configurar needs: por costumbre.

Cómo corregir: needs: solo cuando hay dependencia real (output de un stage usado por otro). Code review y security scan ambos dependen de extract-diff, pero no entre ellos. Pueden correr en paralelo.

2. Diff extraído 3 veces (uno por stage)

Síntoma: Cada stage hace su propio git diff. Repetir lo mismo 3 veces.

Por qué pasa: Cada job es independiente y nadie centralizó la extracción.

Cómo corregir: Job extract-diff que corre primero y publica como artifact. Los demás stages lo descargan.

3. Comments duplicados o conflictivos

Síntoma: PR con 5 comments del bot — uno por job y dos del summary.

Por qué pasa: Cada stage postea su comment, sumary también, sin coordinación.

Cómo corregir: Markers únicos por tipo de comment (<!-- code-review-bot -->, <!-- security-scan-bot -->, <!-- pr-review-summary -->). Cada stage actualiza su comment en lugar de crear nuevo.

4. Workflow tarda 10+ minutos

Síntoma: Phase 1 que debería ser ~5 min se demora 12.

Por qué pasa: Sin paralelización, sin cache, sin filtrado.

Cómo corregir: Aplicar las 5 optimizaciones de arriba. Phase 1 debería ser ~5 min con paralelización + cache.

5. if: always() en summary que oculta failures

Síntoma: Summary muestra "todo OK" pero un job falló.

Por qué pasa: El summary tiene if: always() para correr incluso con fails, pero el body no refleja correctamente los status.

Cómo corregir: El summary recibe ${{ needs.X.result }} y construye el comment basado en esos status. La función build_summary() arriba lo hace correctamente.


Diagnóstico

Pregunta 1: ¿Tu workflow tiene los 3 stages corriendo en paralelo?

Si están secuenciales, el pipeline tarda 3x más. Paralelización con needs: extract-diff pero independientes entre sí.

Pregunta 2: ¿Cada stage tiene su propio comment con marker único?

Markers permiten actualizar en lugar de duplicar. Sin markers, después de 5 pushes hay 15 comments del bot.

Pregunta 3: ¿Tu workflow respeta paths filter y skipea drafts?

Sin estos filtros, gastas tokens en cosas que no necesitan review (docs, work-in-progress).

Pregunta 4: ¿Tienes job de summary consolidado?

Sin summary, el equipo tiene que mirar 3 lugares. Con summary, todo en un comment.

Pregunta 5: ¿Configuraste `concurrency` para cancelar runs viejos?

Sin esto, 5 pushes seguidos = 5 runs paralelos = 5x los costos.


Ejercicios

Ejercicio 1: Workflow básico (Medio)

Implementa el workflow con:

  1. Job extract-diff
  2. 3 jobs paralelos (code-review, security-scan, tests)
  3. Job summary con consolidación

Probarlo en un PR de prueba. Verifica:

  • Los 3 jobs corren en paralelo
  • El summary refleja correctamente los status
  • Tarda menos que la suma de los 3 individualmente

Ejercicio 2: Optimizaciones (Fácil)

Agrega las 5 optimizaciones:

  1. Cache de pip
  2. Concurrency con cancel
  3. Paths filter
  4. Skip drafts
  5. Skip PRs sin diff relevante

Medir el tiempo antes y después.

Ejercicio 3: Markers únicos por stage (Difícil)

Modifica los scripts (code review, security scan, summary) para que cada uno tenga su propio marker y actualice comments existentes en lugar de crear nuevos.

Probarlo: 3 pushes al mismo PR debería resultar en exactamente 3 comments del bot al final, no 9.


Resumen

  • Phase 1 paralela: code review + security scan + tests + linting corren simultáneamente
  • Job común extract-diff evita duplicar trabajo en cada stage
  • Job de summary consolidado da vista panorámica
  • Markers únicos (<!-- code-review-bot -->, etc.) evitan duplicación de comments
  • 5 optimizaciones clave: cache, concurrency, paths filter, skip drafts, skip si no hay diff
  • Reuso de scripts de Módulos 2 y 5 — no reescribir
  • Phase 1 target: ~5 min desde push hasta summary visible

Próxima cápsula: 04 — Implementación: Stages 4-6 (Deployment). Tienes Phase 1 funcionando. Ahora implementas Phase 2-3: changelog, readiness, deploy a staging, approval gate, deploy a producción. La parte que mueve código del repo al ambiente de usuarios.


Recursos Adicionales

  1. GitHub Actions: jobs in parallel — Sintaxis de paralelización
  2. GitHub Actions: caching — Cache strategies
  3. actions/upload-artifact — Pasar archivos entre jobs
  4. ruff — Linter ultra-rápido
  5. Codecov — Coverage tracking
  6. GitHub Actions: workflow status — Acceder a status de jobs anteriores