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
- Fail fast: Lint antes que tests (más rápido)
- Paralelizar: Jobs independientes en paralelo
- Cache: Dependencias y Docker layers
- 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
| Secret | Descripción |
|---|---|
STAGING_HOST | IP del servidor staging |
PRODUCTION_HOST | IP del servidor producción |
STAGING_USER / PRODUCTION_USER | Usuario SSH |
SSH_PRIVATE_KEY | Clave privada RSA |
CODECOV_TOKEN | Token de Codecov |
Configurar Environments
Settings → Environments → New environment
Para production:
- Required reviewers: Añadir aprobadores
- Wait timer: 5 minutos (opcional)
- Deployment branches: Solo
maino tags
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
- Require a pull request before merging
- Require approvals: 1
- Dismiss stale approvals
- Require status checks to pass
- lint
- test
- Require branches to be up to date
- Do not allow bypassing the above settings
11. Comparativa con Otras CI
| Aspecto | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Config | YAML en repo | YAML en repo | Groovy/UI |
| Runners | Cloud (gratis) | Cloud/Self-hosted | Self-hosted |
| Secrets | UI + Environments | UI + Variables | Credentials |
| Docker | Nativo | Nativo | Plugin |
| Caching | actions/cache | Nativo | Plugin |
| Matrix | Sí | Sí | Plugin |
Conclusión
GitHub Actions permite automatizar todo el ciclo de vida:
- Lint + Type check antes de tests (fail fast)
- Tests con services (Postgres, Redis)
- Build + Push a registry con tags inmutables
- Deploy con health checks y rollback
- 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.