CI/CD para Python con GitHub Actions
Para añadir CI a un proyecto Python, crea .github/workflows/ci.yml, instala el proyecto con sus dependencias de desarrollo y ejecuta Ruff, mypy, pytest con cobertura y el build. El workflow funcional de esta guía prueba Python 3.11, 3.12 y 3.13, activa la caché de pip, limita permisos y guarda las distribuciones como artefacto. Solo debes adaptar el nombre del paquete y las versiones compatibles.
Así, cada push y pull request recibe una verificación objetiva antes del merge: estilo, tipos, comportamiento y empaquetado se comprueban en un entorno limpio. La publicación en producción queda como una fase separada y opcional.
Qué son CI, entrega continua y despliegue continuo
La integración continua (CI) consiste en integrar cambios pequeños con frecuencia y validarlos de forma automática. En Python, una base útil incluye lint, formato, pruebas, cobertura, análisis estático de tipos y creación del paquete. La CI detecta problemas cuando el cambio todavía es pequeño y quien lo escribió conserva el contexto.
Las siglas CD describen dos prácticas relacionadas:
- Entrega continua (continuous delivery): cada revisión aceptada queda lista para publicar, pero una persona o aprobación de entorno decide cuándo hacerlo.
- Despliegue continuo (continuous deployment): cada revisión que supera los controles se publica automáticamente.
Por tanto, CI/CD no obliga a desplegar en producción después de cada merge. Es perfectamente válido combinar CI y entrega continua con una aprobación manual. Una aplicación en contenedores puede extender el proceso con la guía de Docker para Python. Para bibliotecas, consulta cómo publicar un paquete Python en PyPI.
Cómo funciona .github/workflows
GitHub carga los archivos YAML ubicados en .github/workflows/. Cada archivo es un workflow: on selecciona eventos, permissions limita el token y jobs contiene unidades independientes que se ejecutan en runners limpios. Los jobs contienen pasos ordenados. La documentación oficial de GitHub Actions es la referencia para sintaxis, eventos y runners.
Una estructura convencional es suficiente:
mi-proyecto/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── ci.yml
├── src/mi_paquete/
├── tests/
├── pyproject.toml
└── README.md
No acumules CI, releases y despliegues en un archivo enorme. Necesitan eventos y permisos distintos. Separar ci.yml de release.yml facilita la revisión de seguridad e impide que un job normal de pruebas herede credenciales de producción.
Dependencias coherentes en pyproject.toml
Todas las herramientas invocadas por CI deben proceder de dependencias instaladas. Este ejemplo define una extra dev compartida por el equipo y el runner:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mi-paquete"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = [
"build>=1.2,<2",
"mypy>=1.10,<2",
"pytest>=8,<9",
"pytest-cov>=5,<7",
"ruff>=0.5,<1",
]
[tool.pytest.ini_options]
addopts = "--strict-markers"
testpaths = ["tests"]
[tool.coverage.run]
source = ["mi_paquete"]
[tool.coverage.report]
fail_under = 90
show_missing = true
[tool.mypy]
python_version = "3.11"
strict = true
[tool.ruff]
target-version = "py311"
line-length = 100
Los rangos controlan actualizaciones sin aceptar automáticamente futuras versiones mayores. Una aplicación puede preferir un lockfile; revisa las alternativas en la guía de gestión de dependencias Python con pip. Lo esencial es utilizar los mismos comandos de instalación y validación en local y en CI.
Workflow completo para Python
Crea .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install project
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Ruff
run: |
ruff check .
ruff format --check .
- name: Mypy
run: mypy src
- name: Tests and coverage
run: pytest --cov=mi_paquete --cov-report=term-missing --cov-report=xml
- name: Build distributions
if: matrix.python-version == '3.13'
run: python -m build
- name: Upload distributions
if: matrix.python-version == '3.13'
uses: actions/upload-artifact@v4
with:
name: python-distributions
path: dist/
if-no-files-found: error
retention-days: 7
checkout@v4 descarga la revisión y setup-python@v5 instala cada intérprete. Su caché almacena descargas de pip, no un entorno virtual completo. El manifiesto indicado participa en la clave, de modo que un cambio de dependencias produce la caché adecuada. fail-fast: false deja terminar todas las combinaciones y muestra si un fallo pertenece a una versión concreta.
Ruff valida reglas y formato según su documentación oficial. Automatiza convenciones relacionadas con la guía completa de PEP 8 sin convertir la revisión en un debate de espacios. Mypy comprueba contratos de tipos; combina la documentación de mypy con esta guía de type hints en Python.
Pytest muestra las líneas no cubiertas y genera coverage.xml. La opción fail_under = 90 hace fallar la ejecución si no se alcanza el umbral. La cobertura demuestra ejecución, no la calidad de las aserciones; diseña casos relevantes con la guía de pruebas automatizadas con pytest.
El build se ejecuta solo con Python 3.13 para no crear tres veces la misma wheel de Python puro. python -m build genera wheel y distribución fuente en dist/, y upload-artifact@v4 las conserva durante siete días. Un workflow de release posterior puede descargar el artefacto revisado en vez de reconstruir bytes diferentes.
Permisos mínimos, secrets y OIDC
permissions: contents: read aplica mínimo privilegio al GITHUB_TOKEN. Un workflow de validación no necesita escribir en el repositorio. Evita write-all; concede a cada job de publicación solamente las capacidades individuales que requiere.
Los secrets de repositorio o environment son apropiados para tokens que inevitablemente deben almacenarse. Nunca los imprimas, los coloques en argumentos visibles ni los entregues a código no confiable de un pull request. Las restricciones que GitHub aplica a forks y Dependabot evitan rutas de exfiltración; no las anules con soluciones improvisadas.
Para nubes y servicios compatibles, prefiere OpenID Connect (OIDC). Un job de despliegue recibe id-token: write e intercambia su identidad de GitHub por una credencial temporal del proveedor. No queda una clave duradera que filtrar o rotar. Restringe la política de confianza por repositorio, branch, tag o environment y sigue la guía oficial de seguridad de despliegues con OIDC. Añade ese permiso solo al job de deploy, nunca a la CI anterior.
GitHub Environments permite exigir revisores, restringir branches y aislar secrets. Esto implementa entrega continua: CI produce un artefacto probado y producción espera aprobación. El despliegue continuo es opcional y solo conviene cuando existen pruebas fiables, observabilidad y rollback.
Protección de branch y checks obligatorios
Un workflow verde es meramente informativo si las reglas permiten ignorarlo. En Settings > Rules > Rulesets (o protección de branches), exige pull request, aprobaciones y los checks Python 3.11, Python 3.12 y Python 3.13. Otra opción es un job resumen de nombre estable si la matriz cambia con frecuencia. Impide el merge con conversaciones pendientes y limita estrictamente el bypass.
Los checks obligatorios se identifican por nombre. Si renombras un job sin actualizar la regla, el pull request puede esperar para siempre un check inexistente. La automatización tampoco evalúa arquitectura o nombres en contexto; combínala con las buenas prácticas de Clean Code en Python.
Dependabot y badge de estado
Haz visibles las actualizaciones de actions y paquetes mediante .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
Revisa las notas de versión y deja que CI valide cada pull request. Automatizar la propuesta no implica aprobarla sin controles. Añade este badge al README.md, sustituyendo los marcadores:
[](https://github.com/OWNER/REPO/actions/workflows/ci.yml)
El badge informa del último resultado; no certifica seguridad ni calidad absoluta.
Optimización sin volver frágil la CI
Empieza por la corrección y revisa cuánto tarda cada paso antes de optimizar. La caché pip de setup-python evita descargas repetidas. Apunta cache-dependency-path al manifiesto o lockfile real y considera toda caché descartable: una ejecución fría debe seguir funcionando.
El grupo concurrency cancela ejecuciones obsoletas de la misma referencia cuando llega otro commit. timeout-minutes contiene procesos bloqueados. Construye el paquete una vez, pero conserva pruebas en todos los intérpretes compatibles. En repositorios grandes, lint y tipos pueden vivir en un job sin matriz mientras las pruebas mantienen la matriz completa.
Guardar .venv en caché rara vez es una buena opción: rutas absolutas, binarios nativos, versiones y cambios de imagen la vuelven frágil. Tampoco uses continue-on-error en un control obligatorio solo para mostrar un badge verde. Optimizar significa eliminar trabajo duplicado, no silenciar señales.
Errores frecuentes
- Ruta incorrecta: GitHub solo reconoce workflows dentro de
.github/workflows/. - Herramienta ausente: ejecutar Ruff o mypy sin instalar la extra
devtermina en comando no encontrado. - Errores de importación: instala el paquete con
pip install -e ".[dev]"en vez de modificarPYTHONPATH. - Caché incoherente: incluye el manifiesto que realmente controla dependencias en
cache-dependency-path. - Cobertura engañosa: un porcentaje alto todavía puede omitir límites, errores y comportamiento del usuario.
- Matriz incorrecta: alinea sus versiones con
requires-pythony la política de soporte documentada. - Permisos excesivos: tokens de escritura o secrets en código de pull request agravan ataques a la cadena de suministro.
- Deploy dentro del job de CI: producción comparte entonces eventos, credenciales y riesgo con la validación.
Checklist para producción
- [ ]
pyproject.tomldeclara los Python compatibles y una extradevcompleta. - [ ] Ruff, mypy, pytest y build usan los mismos comandos en local y CI.
- [ ] La matriz cubre todos los intérpretes oficialmente compatibles.
- [ ] La cobertura tiene un umbral útil y las pruebas verifican comportamiento.
- [ ] El workflow limita contenido a lectura y define timeout y concurrencia.
- [ ] Las distribuciones son artefactos con una retención explícita.
- [ ] Reviews y checks obligatorios protegen la branch principal.
- [ ] Dependabot propone actualizaciones de pip y GitHub Actions para revisión.
- [ ] Los secrets son mínimos y los despliegues compatibles utilizan OIDC.
- [ ] Release y deploy están separados, con environment y aprobación cuando corresponda.
Esta base vuelve reproducible la integración, trazables los artefactos de entrega y consciente la decisión de desplegar. El mejor pipeline no es el que acumula más pasos, sino el mínimo que bloquea defectos relevantes, reduce privilegios y continúa siendo comprensible para todo el equipo.