Llevaba como tres meses postergando esto. Nuestro equipo de cuatro personas tenía un proyecto FastAPI que corría tests localmente "cuando alguien se acordaba", y el deploy era... bueno, el deploy era yo ejecutando rsync desde mi máquina. Elegante no era.
Así que un martes por la tarde me puse a configurar GitHub Actions. "Dos horas máximo", pensé. Seis días después, con el historial de commits más vergonzoso que he tenido en mi carrera, tenía algo que funcionaba de verdad. Esto es lo que aprendí.
El Primer Workflow que Escribí Rompió Todo en 47 Segundos
El objetivo inicial era simple: que los tests corrieran en cada push. La documentación oficial de GitHub es decente, así que copié el ejemplo de Python que aparece ahí, lo pegué en .github/workflows/ci.yml, hice push. Falló.
El error era:
Error: No module named 'pytest'
Obvio en retrospectiva. Estaba instalando dependencias con pip install -r requirements.txt, pero mi requirements.txt de producción no incluía pytest. Lo tenía en requirements-dev.txt separado. Primer aprendizaje: el entorno de CI es un entorno limpio de verdad — no hay nada preinstalado, no hay caché del sistema, no hay ese paquete que instalaste hace seis meses y ya ni recuerdas.
La estructura base que terminé usando, después de varios intentos fallidos:
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Configurar Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Instalar dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Correr tests
run: pytest tests/ -v --tb=short
Simple. Funciona. Pero lento — 3 minutos 40 segundos en cada run solo por la instalación de dependencias. Y aquí es donde la cosa se pone interesante.
Caché de Dependencias: De 3 Minutos 40 a 28 Segundos
Esto tuvo el mayor impacto en nuestro flujo de trabajo, y también donde pasé más tiempo confundido.
El mecanismo es simple: guardas un directorio entre runs, asociado a una clave. Si la clave coincide, usas lo guardado. Si no, instalas todo de nuevo y guardas el resultado para la próxima vez. La clave típica es un hash del archivo de dependencias — si requirements.txt no cambió, el hash es el mismo y te saltás la instalación.
actions/setup-python@v5 tiene caché integrado, lo cual está bien. Pero tardé más de lo que me gustaría admitir en entender por qué a veces el caché no se activaba. Si tenés dependencias separadas (dev vs. prod, como yo), necesitás especificarlo explícitamente:
- name: Configurar Python con caché
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
requirements.txt
requirements-dev.txt
Ese cache-dependency-path con múltiples archivos fue la pieza que me faltaba. Antes usaba solo requirements.txt y el caché se invalidaba cada vez que instalaba algo nuevo en dev. Con esto, se invalida si cambia cualquiera de los dos archivos.
Si usas Poetry — y si estás empezando un proyecto nuevo hoy, considerá uv en serio, es notablemente más rápido que pip para instalación — la configuración cambia un poco:
- name: Instalar Poetry
uses: snok/install-poetry@v1
with:
version: "1.8.2"
virtualenvs-create: true
virtualenvs-in-project: true
- name: Caché de dependencias
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('poetry.lock') }}
- name: Instalar dependencias
run: poetry install --no-interaction
Punto clave: usá poetry.lock para el hash, no pyproject.toml. El lockfile tiene los hashes exactos de cada paquete — es el archivo que realmente determina qué se instala. Con pyproject.toml podrías estar cacheando una versión desactualizada sin darte cuenta.
Matrices de Versiones: Tres Pythons en Paralelo Sin Complicarte la Vida
Una vez que el workflow básico corría bien, quise verificar compatibilidad con Python 3.10, 3.11 y 3.12 al mismo tiempo. Honestamente, la estrategia de matrices de GitHub Actions fue de lo poco que me sorprendió bien durante todo el proceso — esperaba tener que escribir tres jobs a mano.
La idea: definís una lista de valores y GitHub crea un job por cada combinación, en paralelo, automáticamente.
jobs:
test:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Configurar Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: |
requirements.txt
requirements-dev.txt
- name: Instalar dependencias
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Tests
run: pytest tests/ -v --tb=short
El fail-fast: false merece un párrafo propio. Por defecto, si un job de la matriz falla, GitHub cancela los demás. Eso parece eficiente, pero en práctica es frustrante: si Python 3.10 falla por algo de compatibilidad, querés saber si 3.11 y 3.12 pasan también. Con fail-fast: false, todos corren hasta el final y ves el panorama completo.
Algo que no esperaba: el caché es por versión de Python. Cada entrada de la matriz tiene su propio caché separado. Lógico una vez que lo pensás, pero me costó un momento de "¿por qué está instalando todo de nuevo en Python 3.10 si 3.12 ya lo tenía cacheado?" antes de entenderlo.
¿Testear también en múltiples sistemas operativos? Podés extender la matriz así: os: [ubuntu-22.04, windows-latest, macos-latest]. Nosotros no lo hacemos — deployamos en Linux y somos cuatro personas, así que agregar seis jobs más por run sería ruido más que señal. Depende del proyecto.
Linting y Tipos: Donde Perdí Una Tarde Entera un Viernes
Okay, esto fue el momento más frustrante de todo el proceso.
Quería agregar linting y type checking al pipeline. El stack que uso localmente es ruff para linting y formateo (migré de flake8 + black + isort hace unos meses y no miro atrás), y mypy para tipos. Pensé que sería copy-paste de la configuración local al workflow. No lo fue.
El problema con mypy en CI es que necesita los stubs de todos tus paquetes. Localmente tenía instalados types-requests, types-redis y otros paquetes de stubs manualmente — pero no estaban en requirements-dev.txt. Así que mypy corría en CI y se quejaba de absolutamente todo. Empujé esa configuración un viernes por la tarde y al lunes me encontré con 23 errores nuevos en el pipeline, ninguno de los cuales era un error real de tipos — todos eran stubs faltantes. Clásico.
La configuración que quedó funcionando:
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Configurar Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: requirements-dev.txt
- name: Instalar herramientas
run: pip install ruff mypy types-requests types-redis
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
# --check verifica sin modificar archivos — fundamental en CI
- name: Mypy
run: mypy src/ --ignore-missing-imports
El --ignore-missing-imports en mypy es un compromiso que no me encanta. La alternativa es mantener una lista exhaustiva de paquetes de stubs, y con dependencias que cambian regularmente, esa lista se desactualiza sola. No estoy 100% seguro de que esto escale bien más allá de equipos pequeños — si alguien tiene un enfoque mejor, me interesa genuinamente saberlo.
Lo que sí me gustó de este setup: ruff tarda 4 segundos en el pipeline. Con flake8 + black + isort por separado tardaba alrededor de 25. No es crítico, pero cuando tenés 15 PRs abiertos en el día, esos segundos se acumulan.
Cómo Quedó el Workflow Final y Qué Haría Diferente
Después de todo este proceso, el archivo que tenemos en producción hace tres cosas en paralelo: tests en Python 3.10-3.12, linting con ruff y mypy, y un check rápido de seguridad con pip audit. Los tres jobs corren al mismo tiempo — no hay razón para que linting espere a que terminen los tests. Eso recortó el tiempo total de casi 6 minutos a poco más de 2.
Si tuviera que empezar de nuevo, configuraría las notificaciones de Slack desde el primer día. Tardamos semanas en darnos cuenta de que teníamos branches con tests fallando porque nadie revisaba el ícono de estado en los PRs con suficiente cuidado. GitHub tiene integración con Slack bastante directa — no es mucho trabajo configurarlo, y vale la pena hacerlo antes de que el equipo desarrolle el hábito de ignorar los íconos rojos.
También usaría continue-on-error: true en el job de mypy durante las primeras semanas de adoptarlo en un proyecto existente. Mypy en un proyecto nuevo es fácil. Mypy en un proyecto con 30k líneas que nunca tuvo tipos es otra historia — si lo ponés en modo bloqueante desde el principio, vas a tener el pipeline en rojo permanente hasta que alguien tenga tiempo de ir tipo por tipo. Mejor hacerlo pasar, ver los errores como warnings, e ir arreglándolos iterativamente.
Empezá con el workflow más simple posible — checkout, setup-python con caché, instalar dependencias, correr pytest. Hacé que eso funcione antes de agregar linting, matrices o cualquier otra cosa. Si agregás cinco cosas a la vez y algo falla, vas a perder tiempo tratando de aislar qué rompió qué. Yo lo hice mal y pagué el precio.
El caché de dependencias es el cambio de mayor impacto por menor esfuerzo — quince minutos de configuración para cortar el tiempo de build a la mitad o menos. Hacelo desde el principio, no después.
Y si estás empezando un proyecto nuevo, usá uv. El workflow cambia un poco pero la instalación de dependencias es tan rápida que casi hace irrelevante el caché de todas formas.
Top comments (0)