Módulo 5: Proyecto — CI/CD Pipeline Completo
3. Workflows reutilizables
Qué cubre esta cápsula
Tu pipeline tiene 300+ líneas de YAML en un solo archivo (ci.yml). Cuando agregas un nuevo deploy target o modificas el setup de Python, tocas varios bloques duplicados. Y si en el futuro tienes un segundo repo con un pipeline similar, copias todo de nuevo. Es el momento de aplicar DRY (Don't Repeat Yourself) en CI/CD.
Esta cápsula te enseña reusable workflows con workflow_call — el patrón que permite definir un workflow una vez y llamarlo desde otros. Vas a refactorizar tu pipeline en módulos: _reusable-tests.yml, _reusable-quality.yml, _reusable-deploy.yml. Mismo comportamiento, mucho más mantenible y reutilizable entre repos.
Al terminar, podrás:
- Crear reusable workflows con
workflow_calltrigger - Pasar inputs y outputs entre workflows
- Pasar secrets de forma segura a reusable workflows
- Refactorizar tu pipeline monolítico en módulos
- Reusar workflows entre repos (con considerations de cross-repo)
- Decidir cuándo extraer un job como reusable vs dejarlo inline
El problema: YAML duplicado y pipelines difíciles de mantener
Tu pipeline actual:
# .github/workflows/ci.yml — 300+ líneas
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -e ".[dev]"
- run: pytest -v --cov=app --cov-fail-under=80
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -e ".[dev]"
- uses: pre-commit/action@v3.0.1
# ... 200 líneas más de deploy-dev, deploy-staging, deploy-production
Problemas:
-
Duplicación: los pasos "setup Python + install deps" aparecen en
test,quality,deploy-*. Cualquier cambio (e.g., agregar--upgrade pip) requiere editar varios lugares. -
Difícil de leer: 300+ líneas en un archivo. Encontrar bugs requiere scrollear.
-
No reusable entre repos: si tienes otro repo Python con un pipeline similar, copias todo y mantienes dos versiones desalineadas.
-
Hard to test: modificar el workflow requiere abrir un PR; no puedes "testear el workflow" sin disparar todo el pipeline.
Reusable workflows resuelven esto.
El modelo mental: workflows como funciones
Piensa un reusable workflow como una función en programación:
# Antes (sin función):
print("Setting up...")
run_setup()
run_tests("python3.10")
print("Setting up...")
run_setup()
run_tests("python3.11")
print("Setting up...")
run_setup()
run_tests("python3.12")
# Después (con función):
def setup_and_test(version):
print("Setting up...")
run_setup()
run_tests(version)
setup_and_test("python3.10")
setup_and_test("python3.11")
setup_and_test("python3.12")
Reusable workflows = funciones de YAML.
# Antes (sin reusable):
# 300 líneas con steps duplicados
# Después (con reusable):
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
with:
python-versions: '["3.10", "3.11", "3.12"]'
quality:
uses: ./.github/workflows/_reusable-quality.yml
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
Same behavior, mucho menos código en el caller. El detalle vive en los archivos _reusable-*.yml.
Step 1: estructura de archivos
Organiza los workflows así:
.github/workflows/
├── ci.yml # entry point: corre en PRs y main
├── cd.yml # entry point: corre solo en main
├── _reusable-tests.yml # llamado por ci.yml
├── _reusable-quality.yml # llamado por ci.yml
├── _reusable-build-push.yml # llamado por cd.yml
├── _reusable-deploy.yml # llamado por cd.yml (3 veces — dev/staging/prod)
└── rollback.yml # manual trigger
Convención:
- Workflows con
_prefix son internal (reusable, no triggered directamente) - Workflows sin prefix son entry points (triggered por events)
Step 2: tu primer reusable workflow
Empieza con el más simple: el job test. Extráelo a _reusable-tests.yml:
# .github/workflows/_reusable-tests.yml
name: Reusable tests
on:
workflow_call:
inputs:
python-versions:
description: 'JSON array of Python versions to test'
required: false
default: '["3.10", "3.11", "3.12"]'
type: string
coverage-threshold:
description: 'Minimum coverage percentage'
required: false
default: 80
type: number
jobs:
test:
name: Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(inputs.python-versions) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'pyproject.toml'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: |
pytest -v \
--cov=app --cov-branch \
--cov-report=term-missing \
--cov-fail-under=${{ inputs.coverage-threshold }} \
-m "not slow"
Características clave:
on: workflow_call:
Este trigger hace el workflow reusable. No se dispara por eventos normales — solo cuando otro workflow lo llama.
inputs:
Parámetros que el caller puede pasar:
python-versions: array de versiones (default: 3.10, 3.11, 3.12)coverage-threshold: número (default: 80)
Tipos soportados: string, number, boolean, choice.
El job interno usa los inputs
python-version: ${{ matrix.python-version }}
# matrix.python-version viene del array que el caller pasó
Step 3: el caller
ci.yml ahora llama al reusable:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
# No necesitamos pasar inputs — usa los defaults
quality:
uses: ./.github/workflows/_reusable-quality.yml
ci-success:
name: CI Success
needs: [test, quality]
if: always()
runs-on: ubuntu-latest
steps:
- if: needs.test.result != 'success' || needs.quality.result != 'success'
run: exit 1
- run: echo "✅ CI passed"
De 300 líneas a 25. Misma funcionalidad. La complejidad está en los archivos _reusable-*.yml.
Step 4: reusable workflow con secrets
El job deploy necesita secrets específicos. Los reusable workflows reciben secrets de tres formas:
Forma A: secrets explícitos como inputs
# _reusable-deploy.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
RAILWAY_TOKEN:
required: true
DATABASE_URL:
required: true
DEPLOY_URL:
required: true
Caller los pasa explícitamente:
deploy-production:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEPLOY_URL: ${{ secrets.DEPLOY_URL }}
Pros:
- Explícito: el caller declara qué secrets pasa
- Seguro: el reusable no recibe más secrets de los que necesita
Cons:
- Verboso si hay muchos secrets
Forma B: secrets: inherit (la más simple)
deploy-production:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
inherit hace que todos los secrets del caller estén disponibles en el reusable. Mucho más simple.
Pros:
- Una línea
- Si agregas un secret nuevo, no necesitas actualizar el caller
Cons:
- Menos explícito (el reusable recibe todos los secrets, no solo los que necesita)
Recomendación: secrets: inherit para reusable workflows internos (mismo repo). Forma A para workflows que quieres reusar entre repos con secrets distintos.
Step 5: reusable workflow completo para deploy
# .github/workflows/_reusable-deploy.yml
name: Reusable deploy
on:
workflow_call:
inputs:
environment:
description: 'Target environment (dev/staging/production)'
required: true
type: string
image-tag:
description: 'Docker image tag to deploy'
required: false
default: 'latest'
type: string
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -e ".[dev]"
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: alembic upgrade head
- name: Redeploy Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
npm install -g @railway/cli
railway redeploy --service ${{ secrets.RAILWAY_SERVICE_ID }}
- name: Health check
run: |
DEPLOY_URL="${{ secrets.DEPLOY_URL }}"
for i in $(seq 1 24); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DEPLOY_URL/health" || echo "000")
[ "$CODE" = "200" ] && exit 0
sleep 5
done
exit 1
El truco: environment: ${{ inputs.environment }} hace que el job use los secrets del environment correspondiente. Mismo workflow, distintos secrets según el input.
Step 6: el caller cd.yml
# .github/workflows/cd.yml
name: CD
on:
push:
branches: [main]
jobs:
# Reuso del CI (corre de nuevo en main como seguridad)
ci:
uses: ./.github/workflows/ci.yml
build-and-push:
needs: ci
uses: ./.github/workflows/_reusable-build-push.yml
deploy-dev:
needs: build-and-push
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: dev
secrets: inherit
deploy-staging:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: staging
secrets: inherit
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
Tres llamadas al mismo reusable workflow. Cada una con un environment distinto. DRY al máximo.
Si en el futuro agregas un environment qa:
deploy-qa:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: qa
secrets: inherit
Una línea más en cd.yml. Cero cambios en _reusable-deploy.yml.
Step 7: outputs entre workflows
A veces un reusable workflow produce datos que el caller necesita:
# _reusable-build-push.yml
on:
workflow_call:
outputs:
image-tag:
description: 'The tag of the pushed image'
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tags }}
steps:
# ... build steps
- id: meta
run: |
TAG="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}"
echo "tags=$TAG" >> $GITHUB_OUTPUT
Caller usa el output:
# cd.yml
jobs:
build:
uses: ./.github/workflows/_reusable-build-push.yml
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying image ${{ needs.build.outputs.image-tag }}"
Patrón útil: un workflow produce algo (image tag, versión number, build artifact path) y los siguientes lo consumen.
Step 8: refactor de tu pipeline actual
Plan de migración paso a paso:
Fase 1: extraer _reusable-tests.yml
- Crear
_reusable-tests.ymlcon el contenido del jobtest - Modificar
ci.ymlpara llamarlo - Push a una branch, abre PR
- Verificar que CI sigue funcionando idéntico
- Si OK, merge
Fase 2: extraer _reusable-quality.yml
Mismo proceso. Iterativo y safe.
Fase 3: extraer _reusable-build-push.yml
Fase 4: extraer _reusable-deploy.yml
Esta es la más impactante — un solo reusable usado tres veces (dev/staging/production).
Fase 5: split ci.yml vs cd.yml
Originalmente todo está en ci.yml. Split:
ci.yml: solo jobs que corren en PRs y main (test, quality)cd.yml: solo jobs que corren en main (build, deploy)
Beneficio: PRs solo disparan ci.yml (sin trigger cd.yml). Pipeline más rápido para feedback en PRs.
Cuándo NO usar reusable workflows
Reusable workflows agregan complejidad. No los uses si:
1. El job se usa solo una vez
# ci.yml — el job de linting solo aparece acá
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pre-commit/action@v3.0.1
Extraer esto a _reusable-quality.yml no agrega valor — solo agrega un archivo más. Mantenlo inline.
2. El job es trivial (< 5 líneas)
Si el job tiene 1-2 steps, el overhead de archivos separados > el beneficio.
3. Los inputs hacen el workflow más complejo que la duplicación
# Si necesitas 15 inputs para que el reusable funcione,
# probablemente sea más simple duplicar el job
Cuando llegas a 15+ inputs, la abstracción está mal — probablemente sean dos workflows distintos disfrazados de uno.
4. El trabajo requiere shared state entre steps
Reusable workflows tienen jobs aislados. Si tu trabajo requiere compartir state complejo entre steps, considera custom actions (composite actions) en lugar de reusable workflows.
Composite actions: la alternativa para steps repetidos
Hay otro patrón: composite actions. Útil cuando quieres reusar steps, no jobs completos.
.github/actions/setup-python-deps/action.yml
# .github/actions/setup-python-deps/action.yml
name: 'Setup Python + dependencies'
description: 'Sets up Python and installs dev dependencies'
inputs:
python-version:
description: 'Python version'
required: true
runs:
using: composite
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
cache-dependency-path: 'pyproject.toml'
- shell: bash
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
Uso:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-deps
with:
python-version: '3.12'
- run: pytest
Diferencias:
| Reusable Workflow | Composite Action |
|---|---|
| Reusa jobs completos | Reusa steps dentro de un job |
Triggered con uses: workflow.yml | Triggered con uses: ./.github/actions/X |
| Tiene su propio job context (env, runs-on) | Corre dentro del job del caller |
Puede tener needs:, matrix, etc. | No (es parte del job caller) |
| Más adecuado para: deploy, build pipelines | Más adecuado para: setup steps reusables |
Usar ambos: reusable workflows para jobs (test, deploy), composite actions para setup steps comunes.
Trampas comunes
1. Path al reusable workflow incorrecto
uses: .github/workflows/_reusable-tests.yml # ❌ sin ./
Cómo manejar: el path SIEMPRE empieza con ./ para reusable workflows del mismo repo:
uses: ./.github/workflows/_reusable-tests.yml # ✅
Para reusable workflows de otro repo:
uses: organization/repo/.github/workflows/workflow.yml@main
# o
uses: organization/repo/.github/workflows/workflow.yml@v1.0.0
2. Secrets no inherit
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
# falta secrets: inherit
El reusable corre pero ${{ secrets.X }} está vacío.
Cómo manejar: agregar secrets: inherit o declarar secrets explícitos.
3. Inputs typo
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environement: production # typo: environement vs environment
GitHub no valida el nombre del input. El reusable recibe inputs.environment vacío.
Cómo manejar: test del reusable en un PR antes de mergear. Usar IDE con YAML validation (e.g., GitHub Actions VS Code extension).
4. Versiones desalineadas en cross-repo
Si el reusable vive en org/shared-workflows:
uses: org/shared-workflows/.github/workflows/test.yml@v1.0.0
Si los maintainers actualizan a v1.1.0, tú sigues en v1.0.0. Tienes que mergear manualmente.
Cómo manejar:
- Usar
@mainpara always-latest (con riesgo de break) - Usar tags semver (
@v1.0.0) para estabilidad - Dependabot updatea estas refs automáticamente (cápsula 05)
5. Reusable workflow sin testing
Reusable workflows también son código. Si tienen bugs, todos los repos que los usan rompen.
Cómo manejar:
- CI del repo del reusable workflow incluye un job que llama al workflow en sí
- Tests de integración: pequeño repo "consumer" que usa el workflow y verifica que funciona
- Semantic versioning del reusable: nunca breaking changes en patches
Caso desarrollado: refactor de 300 líneas a modular
Antes (monolito):
.github/workflows/
└── ci.yml (350 líneas, 8 jobs)
Después (modular):
.github/workflows/
├── ci.yml (35 líneas)
├── cd.yml (45 líneas)
├── _reusable-tests.yml (40 líneas)
├── _reusable-quality.yml (25 líneas)
├── _reusable-build-push.yml (50 líneas)
└── _reusable-deploy.yml (60 líneas)
Total LOC: 255 líneas en vez de 350 (25% menos por eliminar duplicación).
Cambios futuros:
- "Agregar Python 3.13 al matrix": editar un lugar (
_reusable-tests.yml) - "Agregar Slack notification al deploy": editar un lugar (
_reusable-deploy.yml) - "Agregar environment QA": editar un lugar (
cd.yml), agregar 5 líneas
Beneficio compuesto: cada cambio toca menos código → menos chance de bugs → más velocidad de iteración.
Ejercicio: refactor de tu pipeline
-
Identifica los jobs que más se beneficiarían de extracción:
- ✅
test: usado en CI - ✅
quality: usado en CI - ✅
deploy-*: 3 jobs casi idénticos (dev/staging/prod) → claro candidato - ❌
ci-success: trivial, mantener inline - ❌
build-and-push: usado solo una vez, marginal
- ✅
-
Extrae
_reusable-deploy.yml(mayor impacto). Verifica que el CI sigue verde. -
Split
ci.ymlvscd.yml. -
Itera con
_reusable-tests.yml,_reusable-quality.yml. -
Mide:
- LOC totales antes vs después
- Tiempo de ejecución del pipeline (debería ser similar)
- Tiempo para hacer un cambio futuro (debería ser menor)
Solución: estructura final
.github/workflows/
├── ci.yml
├── cd.yml
├── rollback.yml
├── _reusable-tests.yml
├── _reusable-quality.yml
├── _reusable-build-push.yml
└── _reusable-deploy.yml
ci.yml:
name: CI
on:
pull_request: { branches: [main] }
push: { branches: [main] }
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
quality:
uses: ./.github/workflows/_reusable-quality.yml
ci-success:
needs: [test, quality]
if: always()
runs-on: ubuntu-latest
steps:
- if: needs.test.result != 'success' || needs.quality.result != 'success'
run: exit 1
cd.yml:
name: CD
on:
push: { branches: [main] }
jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
needs: ci
uses: ./.github/workflows/_reusable-build-push.yml
deploy-dev:
needs: build
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: dev }
secrets: inherit
deploy-staging:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: staging }
secrets: inherit
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: production }
secrets: inherit
Auto-verificación
1. ¿Cuándo extraer un job a reusable workflow vs cuándo mantenerlo inline?
Extraer a reusable cuando:
✅ El job se usa 2 o más veces en el mismo workflow o en workflows distintos.
- Ejemplo: deploy a dev/staging/prod usa el mismo workflow con distinto environment.
✅ El job tiene complejidad significativa (más de 5-10 steps).
- Si es complejo, la abstracción ayuda a mantenerlo.
✅ El job es candidato para reuse entre repos.
- "Cualquier repo Python necesita testear con matrix" → reusable.
✅ El job representa una unidad lógica clara (test, deploy, build).
- Si tiene un nombre fácil ("deploy to environment X"), es buen candidato.
Mantener inline cuando:
❌ El job es trivial (1-3 steps).
- Overhead de archivo separado > beneficio.
❌ Se usa solo una vez y no es candidato para reuse.
- Premature abstraction.
❌ Requiere muchos inputs específicos (>10) que hacen el reusable más complejo que la duplicación.
❌ Es integración específica del repo (tu lógica custom, no patrón generalizable).
Regla práctica: si el job no cumple ninguna razón de "extraer", mantenlo inline. Más fácil promover a reusable después que rebajar a inline.
2. Tu reusable workflow falla en algunos repos pero funciona en otros. ¿Cómo diagnosticas?
Cinco causas comunes, en orden de probabilidad:
1. Diferencias en secrets:
- Repo A tiene
RAILWAY_TOKENconfigurado - Repo B no lo tiene
- Reusable usa
${{ secrets.RAILWAY_TOKEN }}→ empty en repo B - Step falla
Diagnóstico: verificar que el caller pasa los secrets correctos o usa secrets: inherit y que el caller tiene todos los secrets necesarios.
2. Diferencias en archivos:
- Reusable hace
pip install -e ".[dev]"asumiendopyproject.tomlcon extras dev - Repo B tiene
requirements.txten vez depyproject.toml - Comando falla
Diagnóstico: estandarizar el repo (todos usan pyproject.toml) o agregar inputs al reusable para customizar el comando de install.
3. Permisos diferentes del GITHUB_TOKEN:
- Repo A tiene
permissions: packages: writeen el caller - Repo B no lo tiene
- Reusable intenta push a GHCR → 403
Diagnóstico: declarar permisos requeridos en el reusable y verificar que el caller los provee.
4. Versiones de runners:
- Repo A usa
ubuntu-22.04 - Repo B usa
ubuntu-latestque ahora esubuntu-24.04 - Comando depende de versión específica de una tool
Diagnóstico: especificar runs-on explícitamente en el reusable.
5. Caches conflictivos:
- Cache hit/miss diferente entre repos
- Step que depende del cache se comporta distinto
Diagnóstico: revisar la key del cache. Usar keys que incluyen hashFiles('pyproject.toml') para que cambien con cambios reales del repo.
Estrategia general: correr el reusable en modo workflow_dispatch con verbose output (set -x en bash steps) y comparar logs entre repos donde funciona vs donde falla. La línea donde divergen revela la causa.
Resumen y siguiente paso
- Reusable workflows = funciones de YAML; eliminan duplicación y mejoran mantenibilidad
workflow_calltrigger los hace llamables;uses: ./.github/workflows/Xlos invoca- Inputs para parámetros,
secrets: inheritpara credentials (simplest) - Composite actions reusan steps, reusable workflows reusan jobs
- Extraer cuando se usa 2+ veces o es complejo; inline si es trivial o single-use
- Tu pipeline pasa de 350 LOC en un archivo a ~250 LOC en módulos
Puente al próximo paso: Tu pipeline está modular y mantenible. Pero tarda 5 minutos por run — y eso es 5 minutos × 50 PRs/mes × 12 meses = 50+ horas de wait time al año. En la cápsula 04 vas a optimizar con caching (pip cache, Docker layer cache GHA, etc.) para reducir el tiempo a 2 minutos. Mismo pipeline, 2.5× más rápido.
Recursos
- Reusing workflows — documentación oficial.
- Sharing workflows, secrets, and runners with your organization — para reusables cross-repo.
- Creating a composite action — para steps reusables.
- Awesome Actions — catálogo de actions reusables open source.
- Reusable workflows vs composite actions — cuándo usar cada uno.
Cápsula 03 de 08 — Módulo 5 — CI/CD for Python Backend Guide