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_call trigger
  • 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:

  1. Duplicación: los pasos "setup Python + install deps" aparecen en test, quality, deploy-*. Cualquier cambio (e.g., agregar --upgrade pip) requiere editar varios lugares.

  2. Difícil de leer: 300+ líneas en un archivo. Encontrar bugs requiere scrollear.

  3. No reusable entre repos: si tienes otro repo Python con un pipeline similar, copias todo y mantienes dos versiones desalineadas.

  4. 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

  1. Crear _reusable-tests.yml con el contenido del job test
  2. Modificar ci.yml para llamarlo
  3. Push a una branch, abre PR
  4. Verificar que CI sigue funcionando idéntico
  5. 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 WorkflowComposite Action
Reusa jobs completosReusa steps dentro de un job
Triggered con uses: workflow.ymlTriggered 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 pipelinesMá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 @main para 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

  1. 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
  2. Extrae _reusable-deploy.yml (mayor impacto). Verifica que el CI sigue verde.

  3. Split ci.yml vs cd.yml.

  4. Itera con _reusable-tests.yml, _reusable-quality.yml.

  5. 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_TOKEN configurado
  • 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]" asumiendo pyproject.toml con extras dev
  • Repo B tiene requirements.txt en vez de pyproject.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: write en 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-latest que ahora es ubuntu-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_call trigger los hace llamables; uses: ./.github/workflows/X los invoca
  • Inputs para parámetros, secrets: inherit para 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

  1. Reusing workflows — documentación oficial.
  2. Sharing workflows, secrets, and runners with your organization — para reusables cross-repo.
  3. Creating a composite action — para steps reusables.
  4. Awesome Actions — catálogo de actions reusables open source.
  5. Reusable workflows vs composite actions — cuándo usar cada uno.

Cápsula 03 de 08 — Módulo 5 — CI/CD for Python Backend Guide