Módulo 16 14 min de lectura

16 - CI/CD con GitHub Actions

Pipeline completo: tests, linting, build de imagen, deploy automático y rollbacks.

#github-actions #cicd #automation #devops #deployment

1. Anatomía de un Pipeline Senior

[Push/PR] → [Lint & Type Check] → [Tests] → [Build Image] → [Push Registry] → [Deploy]
                ↓                     ↓            ↓
           [Ruff + Pyright]     [Pytest]    [Trivy Scan]

Principios

  1. Fail fast: Lint antes que tests (más rápido)
  2. Paralelizar: Jobs independientes en paralelo
  3. Cache: Dependencias y Docker layers
  4. Inmutabilidad: Tags con SHA, nunca solo latest

2. Workflow Completo

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  release:
    types: [published]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ==================== LINT & TYPE CHECK ====================
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install uv
        uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true
      
      - name: Install dependencies
        run: uv sync --frozen --dev
      
      - name: Ruff lint
        run: uv run ruff check .
      
      - name: Ruff format check
        run: uv run ruff format --check .
      
      - name: Type check
        run: uv run pyright

  # ==================== TESTS ====================
  test:
    runs-on: ubuntu-latest
    needs: lint  # Solo si lint pasa
    
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Install uv
        uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true
      
      - name: Install dependencies
        run: uv sync --frozen
      
      - name: Run tests
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db
          REDIS_URL: redis://localhost:6379/0
          SECRET_KEY: test-secret-key-for-ci
        run: |
          uv run pytest \
            --cov=app \
            --cov-report=xml \
            --cov-fail-under=80 \
            --junitxml=junit.xml
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml
          fail_ci_if_error: true
      
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: junit.xml

  # ==================== BUILD & PUSH ====================
  build:
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name != 'pull_request'  # No build en PRs
    
    permissions:
      contents: read
      packages: write
    
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
      image_digest: ${{ steps.build.outputs.digest }}
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=
      
      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: false
      
      - name: Scan for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

  # ==================== DEPLOY STAGING ====================
  deploy-staging:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/develop'
    environment: staging
    
    steps:
      - name: Deploy to staging
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/api
            export VERSION=${{ github.sha }}
            docker compose pull
            docker compose up -d --no-deps api
            sleep 10
            curl -sf http://localhost:8000/health || exit 1

  # ==================== DEPLOY PRODUCTION ====================
  deploy-production:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'release'
    environment: production
    
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/api
            export VERSION=${{ github.event.release.tag_name }}
            
            # Backup before deploy
            ./scripts/backup.sh
            
            # Deploy
            docker compose pull
            docker compose up -d --no-deps api
            
            # Health check
            sleep 15
            for i in {1..5}; do
              if curl -sf http://localhost:8000/health; then
                echo "Health check passed"
                exit 0
              fi
              echo "Attempt $i failed, retrying..."
              sleep 5
            done
            
            echo "Health check failed, rolling back..."
            docker compose rollback api
            exit 1

3. Secrets y Environments

Configurar Secrets

Settings → Secrets and variables → Actions → New repository secret
SecretDescripción
STAGING_HOSTIP del servidor staging
PRODUCTION_HOSTIP del servidor producción
STAGING_USER / PRODUCTION_USERUsuario SSH
SSH_PRIVATE_KEYClave privada RSA
CODECOV_TOKENToken de Codecov

Configurar Environments

Settings → Environments → New environment

Para production:


4. Workflow de PR con Checks

# .github/workflows/pr-check.yml
name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      migrations: ${{ steps.filter.outputs.migrations }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'app/**'
              - 'tests/**'
              - 'pyproject.toml'
            migrations:
              - 'alembic/**'

  lint:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync --frozen --dev
      - run: uv run ruff check .
      - run: uv run pyright

  test:
    needs: [changes, lint]
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync --frozen
      - run: uv run pytest --cov=app --cov-fail-under=80
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db

  migration-check:
    needs: changes
    if: needs.changes.outputs.migrations == 'true'
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync --frozen
      - name: Test migrations
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db
        run: |
          uv run alembic upgrade head
          uv run alembic downgrade base
          uv run alembic upgrade head

5. Matrix Testing (Múltiples Versiones)

test:
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      python-version: ['3.11', '3.12', '3.13']
      postgres-version: ['15', '16']
  
  services:
    postgres:
      image: postgres:${{ matrix.postgres-version }}-alpine
      # ...
  
  steps:
    - uses: actions/checkout@v4
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install uv
      run: pip install uv
    
    - run: uv sync --frozen
    - run: uv run pytest

6. Scheduled Jobs (Cron)

# .github/workflows/scheduled.yml
name: Scheduled Tasks

on:
  schedule:
    # Cada día a las 3 AM UTC
    - cron: '0 3 * * *'
  workflow_dispatch:  # Permite ejecución manual

jobs:
  dependency-update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install uv
        uses: astral-sh/setup-uv@v4
      
      - name: Update dependencies
        run: uv lock --upgrade
      
      - name: Create PR if changes
        uses: peter-evans/create-pull-request@v6
        with:
          commit-message: 'chore: update dependencies'
          title: 'chore: update dependencies'
          branch: deps/update
          delete-branch: true

  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          severity: 'CRITICAL,HIGH'

7. Reusable Workflows

# .github/workflows/reusable-test.yml
name: Reusable Test Workflow

on:
  workflow_call:
    inputs:
      python-version:
        required: false
        type: string
        default: '3.12'
    secrets:
      codecov-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
    
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync --frozen
      - run: uv run pytest --cov=app --cov-report=xml
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db
      - uses: codecov/codecov-action@v4
        if: inputs.codecov-token != ''
        with:
          token: ${{ secrets.codecov-token }}
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      python-version: '3.12'
    secrets:
      codecov-token: ${{ secrets.CODECOV_TOKEN }}

8. Rollback Automático

deploy-production:
  runs-on: ubuntu-latest
  environment: production
  
  steps:
    - name: Deploy with rollback
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.PRODUCTION_HOST }}
        username: deploy
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        script: |
          set -e
          cd /opt/api
          
          # Guardar versión actual para rollback
          CURRENT_VERSION=$(docker compose images api --format json | jq -r '.[0].Tag')
          echo "Current version: $CURRENT_VERSION"
          
          # Deploy nueva versión
          export VERSION=${{ github.event.release.tag_name }}
          docker compose pull api
          docker compose up -d --no-deps api
          
          # Health check con reintentos
          MAX_RETRIES=5
          RETRY_COUNT=0
          
          while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
            sleep 10
            if curl -sf http://localhost:8000/health > /dev/null; then
              echo "✅ Deploy successful!"
              exit 0
            fi
            RETRY_COUNT=$((RETRY_COUNT + 1))
            echo "Health check failed, attempt $RETRY_COUNT/$MAX_RETRIES"
          done
          
          # Rollback
          echo "❌ Health check failed, rolling back to $CURRENT_VERSION"
          export VERSION=$CURRENT_VERSION
          docker compose up -d --no-deps api
          
          # Notificar
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-type: application/json' \
            -d '{"text":"⚠️ Production deploy failed, rolled back to '$CURRENT_VERSION'"}'
          
          exit 1

9. Notificaciones

Slack

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "❌ CI/CD Pipeline Failed",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Workflow:* ${{ github.workflow }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}"
            }
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {"type": "plain_text", "text": "View Run"},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

10. Branch Protection Rules

Settings → Branches → Add rule → main

11. Comparativa con Otras CI

AspectoGitHub ActionsGitLab CIJenkins
ConfigYAML en repoYAML en repoGroovy/UI
RunnersCloud (gratis)Cloud/Self-hostedSelf-hosted
SecretsUI + EnvironmentsUI + VariablesCredentials
DockerNativoNativoPlugin
Cachingactions/cacheNativoPlugin
MatrixPlugin

Conclusión

GitHub Actions permite automatizar todo el ciclo de vida:

  1. Lint + Type check antes de tests (fail fast)
  2. Tests con services (Postgres, Redis)
  3. Build + Push a registry con tags inmutables
  4. Deploy con health checks y rollback
  5. Environments para aprobación de producción

Pattern Senior: Usa workflow_call para reutilizar workflows entre repos. Configura branch protection para forzar CI verde antes de merge.

En el siguiente capítulo, implementaremos Task Queues con Celery y Redis.