Módulo 7: Gitops Beyond Terraform

7. Manos a la obra: un pipeline mínimo de aplicación, solo para contrastar

Descripción

Esta es la única lección de todo el Módulo 7 donde algo corre de verdad. Vas a crear un repositorio desechable —fuera de andes-cargo-infra/, sin ningún vínculo con Andes Cargo— con un script de Python trivial, y vas a correr un workflow de exactamente tres pasos: checkout, lint, test, con act, el mismo motor que usaste en toda esta guía. El objetivo no es enseñarte CI/CD de aplicación a fondo —eso es, con precisión, el territorio de cicd-python-backend-guide y testing-in-cicd-guide—, es que la tabla de contraste de la lección 6 deje de ser una afirmación en prosa y se convierta en algo que corriste con tus propios ojos: ningún terraform, ningún LocalStack, ningún host.docker.internal — solo pip install y un intérprete de Python.

Conexión con el módulo

Esta lección cierra el arco que abrió la lección 6: ahí sistematizaste la diferencia en una tabla; aquí la vives. Fíjate en un detalle que vale la pena notar desde el inicio: vas a correr act push, el mismo comando exacto que usaste para ci.yml y apply.yml en los Módulos 3 y 5 — la herramienta no cambia, lo que corre adentro sí. La lección 8, el proyecto de este módulo, no vuelve a este pipeline — usa lo que aprendiste aquí y en las lecciones 2-6 como base de un documento, no de más código.


El proyecto: un repositorio, tres pasos, sin ningún vínculo con Andes Cargo

Crea una carpeta nueva, fuera de andes-cargo-infra/ y fuera de cualquier laboratorio anterior de esta guía:

mkdir app-pipeline-lab && cd app-pipeline-lab
git init
mkdir -p .github/workflows

.actrc — la misma línea que ya usaste en cada laboratorio de esta guía:

-P ubuntu-latest=catthehacker/ubuntu:act-latest

tracking_code.py — el script trivial de esta lección, deliberadamente simple: una sola función que formatea un ID de envío como código de rastreo:

def format_tracking_code(shipment_id: int) -> str:
    """Format a shipment id as an Andes Cargo tracking code, e.g. 4471 -> 'AC-04471'."""
    return f"AC-{shipment_id:05d}"

test_tracking_code.py — dos tests unitarios reales, con assert — exactamente el tipo de prueba que la lección 6 dijo que terraform plan nunca fue:

from tracking_code import format_tracking_code


def test_format_tracking_code_pads_with_zeros():
    assert format_tracking_code(4471) == "AC-04471"


def test_format_tracking_code_handles_large_ids():
    assert format_tracking_code(123456) == "AC-123456"

.github/workflows/app-ci.yml — el pipeline completo, exactamente tres steps con nombre, ni uno más:

name: app-ci

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4

      - name: lint
        run: |
          pip install --break-system-packages ruff
          ruff check .

      - name: test
        run: |
          pip install --break-system-packages pytest
          pytest -v

Compará esto, aunque sea de memoria, contra el ci.yml de nueve steps que cerró el Módulo 3 (terraform fmt, init, validate, instalar awslocal, confirmar la red a LocalStack, instalar tflocal, plan, publicar el resultado). Ese pipeline necesitaba una herramienta externa (Terraform), una conexión de red a un servicio simulado (LocalStack vía host.docker.internal), y una forma de mostrar un cálculo para revisión humana. Este pipeline no necesita nada de eso — pip install trae la herramienta, y el resultado de un test es un PASSED/FAILED binario, no algo que alguien tenga que leer e interpretar como un plan.

--break-system-packages en cada pip install es un detalle real de esta imagen de runner, no un capricho de esta lección: Ubuntu 24.04 (la base de catthehacker/ubuntu:act-latest) protege su instalación de Python del sistema según PEP 668, y sin ese flag, pip install falla — lo vas a ver fallar a propósito en la sección de errores de esta lección, antes de agregarlo.

Confirmá que act detecta el workflow:

git add -A
git -c user.email="you@example.com" -c user.name="you" commit -m "trivial app pipeline for contrast"
act -l

Qué esperar (salida literal, ejecutada para escribir esta lección):

Stage  Job ID  Job name  Workflow name  Workflow file  Events
0      build   build     app-ci         app-ci.yml     push  

Paso 1 — Correr el pipeline completo con act push

act push

Qué esperar (salida literal, ejecutada para escribir esta lección — se omite, por brevedad, la advertencia de arquitectura de Apple Silicon que ya conoces del Módulo 1):

[app-ci/build] ⭐ Run Set up job
[app-ci/build] 🚀  Start image=catthehacker/ubuntu:act-latest
[app-ci/build]   🐳  docker pull image=catthehacker/ubuntu:act-latest platform= username= forcePull=true
[app-ci/build] using DockerAuthConfig authentication for docker pull
[app-ci/build]   🐳  docker create image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[app-ci/build]   🐳  docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[app-ci/build]   🐳  docker exec cmd=[node --no-warnings -e console.log(process.execPath)] user= workdir=
[app-ci/build]   ✅  Success - Set up job
[app-ci/build] ⭐ Run Main checkout
[app-ci/build]   🐳  docker cp src=/ruta/a/tu/laboratorio/. dst=/ruta/a/tu/laboratorio
[app-ci/build]   ✅  Success - Main checkout [26.162792ms]
[app-ci/build] ⭐ Run Main lint
[app-ci/build]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build]   | Collecting ruff
[app-ci/build]   |   Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (26 kB)
[app-ci/build]   | Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (10.7 MB)
[app-ci/build]   | Installing collected packages: ruff
[app-ci/build]   | Successfully installed ruff-0.16.3
[app-ci/build]   | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build]   | All checks passed!
[app-ci/build]   ✅  Success - Main lint [1.943328375s]
[app-ci/build] ⭐ Run Main test
[app-ci/build]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[app-ci/build]   | Collecting pytest
[app-ci/build]   |   Downloading pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
[app-ci/build]   | Collecting iniconfig>=1.0.1 (from pytest)
[app-ci/build]   |   Downloading iniconfig-2.3.0-py3-none-any.whl.metadata (2.5 kB)
[app-ci/build]   | Requirement already satisfied: packaging>=22 in /usr/lib/python3/dist-packages (from pytest) (24.0)
[app-ci/build]   | Collecting pluggy<2,>=1.5 (from pytest)
[app-ci/build]   |   Downloading pluggy-1.6.0-py3-none-any.whl.metadata (4.8 kB)
[app-ci/build]   | Collecting pygments>=2.7.2 (from pytest)
[app-ci/build]   |   Downloading pygments-2.20.0-py3-none-any.whl.metadata (2.5 kB)
[app-ci/build]   | Installing collected packages: pygments, pluggy, iniconfig, pytest
[app-ci/build]   | Successfully installed iniconfig-2.3.0 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1
[app-ci/build]   | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build]   | ============================= test session starts ==============================
[app-ci/build]   | platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3
[app-ci/build]   | cachedir: .pytest_cache
[app-ci/build]   | rootdir: /ruta/a/tu/laboratorio
[app-ci/build]   | collecting ... collected 2 items
[app-ci/build]   | 
[app-ci/build]   | test_tracking_code.py::test_format_tracking_code_pads_with_zeros PASSED  [ 50%]
[app-ci/build]   | test_tracking_code.py::test_format_tracking_code_handles_large_ids PASSED [100%]
[app-ci/build]   | 
[app-ci/build]   | ============================== 2 passed in 0.00s ===============================
[app-ci/build]   ✅  Success - Main test [2.326557959s]
[app-ci/build] ⭐ Run Complete job
[app-ci/build] Cleaning up container for job build
[app-ci/build]   ✅  Success - Complete job
[app-ci/build] 🏁  Job succeeded

(La ruta /ruta/a/tu/laboratorio en docker cp y en rootdir: es la de tu propio app-pipeline-lab/ en tu máquina — variable, igual que ya viste con actions/checkout en el Módulo 2; github.run_id sigue fijo en 1 bajo act, aunque este workflow no lo imprima explícitamente. github.sha variaría según tu commit real, marcado siempre como variable en esta guía, aunque tampoco se imprime aquí.)

Tres cosas para notar, comparándolo con cualquier corrida de ci.yml o apply.yml que ya viviste:

  1. No hay --container-options. Ningún step de este workflow necesita hablar con LocalStack —no hay AWS, no hay host.docker.internal—, así que no hace falta el flag de red que sí fue obligatorio desde el Módulo 2. Es la primera vez, en toda esta guía, que act push corre sin ese flag.
  2. checkout tarda milisegundos, no ejecuta ningún comando de Terraform. El docker cp es idéntico al que ya conoces del Módulo 2 —actions/checkout sigue copiando tu carpeta local en vez de clonar de un GitHub remoto—, pero después de ese step, nunca aparece terraform en ningún lado de esta salida.
  3. El resultado de test es binario: PASSED/PASSED, 2 passed. Compará esto contra la salida de un terraform plan, que nunca dice "pasó" o "falló" en ese sentido — muestra una lista de cambios para que un humano decida si están bien. pytest decide por sí solo si el código hace lo que se espera, sin que nadie tenga que leer un diff.

Paso 2 — Reproducir dos errores reales, a propósito

Error 1 — Olvidar --break-system-packages

Quita el flag de los dos pip install de app-ci.yml (déjalo como estaba, sin ese parámetro) y corre de nuevo:

act push

Qué esperar (salida literal, reproducida para esta lección — el step lint es el primero en usar pip install, así que falla ahí):

[app-ci/build] ⭐ Run Main lint
[app-ci/build]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build]   | error: externally-managed-environment
[app-ci/build]   | 
[app-ci/build]   | × This environment is externally managed
[app-ci/build]   | ╰─> To install Python packages system-wide, try apt install
[app-ci/build]   |     python3-xyz, where xyz is the package you are trying to
[app-ci/build]   |     install.
[app-ci/build]   |     
[app-ci/build]   |     If you wish to install a non-Debian-packaged Python package,
[app-ci/build]   |     create a virtual environment using python3 -m venv path/to/venv.
[app-ci/build]   |     Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
[app-ci/build]   |     sure you have python3-full installed.
[app-ci/build]   | 
[app-ci/build]   | note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
[app-ci/build]   | hint: See PEP 668 for the detailed specification.
[app-ci/build]   ❌  Failure - Main lint [263.5285ms]
[app-ci/build] exitcode '1': failure
[app-ci/build] ⭐ Run Complete job
[app-ci/build]   ✅  Success - Complete job
[app-ci/build] 🏁  Job failed
Error: Job 'build' failed

Este no es un error inventado para la lección — es el comportamiento real de pip sobre Ubuntu 24.04+ desde que adoptó PEP 668 (externally-managed-environment): la instalación de Python del sistema operativo está protegida por defecto, para que pip install no pueda romperla accidentalmente instalando algo que entre en conflicto con paquetes que el propio sistema gestiona vía apt. El mensaje incluso te dice, textualmente, la solución que ya aplicaste: pasar --break-system-packages. Restaurá el flag en los dos pip install antes de seguir.

Error 2 — Un import sin usar, detectado por el linter

Agregá una línea inútil al principio de tracking_code.py:

import os


def format_tracking_code(shipment_id: int) -> str:
    """Format a shipment id as an Andes Cargo tracking code, e.g. 4471 -> 'AC-04471'."""
    return f"AC-{shipment_id:05d}"

Corre de nuevo:

act push

Qué esperar (salida literal, reproducida para esta lección):

[app-ci/build] ⭐ Run Main lint
[app-ci/build]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build]   | Collecting ruff
[app-ci/build]   |   Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (26 kB)
[app-ci/build]   | Installing collected packages: ruff
[app-ci/build]   | Successfully installed ruff-0.16.3
[app-ci/build]   | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build]   | F401 [*] `os` imported but unused
[app-ci/build]   |  --> tracking_code.py:1:8
[app-ci/build]   |   |
[app-ci/build]   | 1 | import os
[app-ci/build]   |   |        ^^
[app-ci/build]   | help: Remove unused import: `os`
[app-ci/build]   |   |
[app-ci/build]   |   - import os
[app-ci/build]   | 1 |
[app-ci/build]   |   |
[app-ci/build]   | 
[app-ci/build]   | Found 1 error.
[app-ci/build]   | [*] 1 fixable with the `--fix` option.
[app-ci/build]   ❌  Failure - Main lint [2.301302292s]
[app-ci/build] exitcode '1': failure
[app-ci/build] ⭐ Run Complete job
[app-ci/build]   ✅  Success - Complete job
[app-ci/build] 🏁  Job failed
Error: Job 'build' failed

F401 es el código de regla exacto de ruff para "import sin usar" —cada regla de ruff tiene un código así, agrupado por familia (F es la familia heredada de Pyflakes)—, y fíjate en el detalle más importante de este error: ruff nunca llegó a correr pytest. El job falló en el step lint, antes de que test tuviera oportunidad de ejecutarse — el mismo comportamiento de "corte temprano" que ya viste en ci.yml, donde un terraform fmt -check fallido detiene el job antes de intentar plan. Quita la línea import os antes de seguir — no la necesitas para nada más de esta lección.


El contraste final, confirmado con tus propios ojos

ci.yml (Módulo 3, infraestructura)app-ci.yml (esta lección, aplicación)
Cantidad de stepsNueveTres
Herramienta externa que instalarTerraform, awslocal, tflocalruff, pytest — instalados con pip, sin Action dedicada
¿Necesita --container-options?Sí, siempre (habla con LocalStack)No, nunca (no toca ningún servicio externo)
Forma del resultadoUn plan de texto, para que un humano lo lea y decidaPASSED/FAILED, una decisión ya tomada por el propio pipeline
¿Qué detiene el job si algo está mal?terraform fmt -check o validate fallidoruff check fallido (como acabas de ver)

Errores comunes

Asumir que el error de PEP 668 es un problema de act, no de Python (de diagnóstico). Qué pasa: alguien ve error: externally-managed-environment y sospecha de la instalación de act o de la imagen del runner. Por qué pasa: cualquier error dentro de un contenedor de act se siente, a primera vista, como un problema de la herramienta. Cómo detectarlo: si el mensaje incluye la frase externally-managed-environment y menciona PEP 668. Cómo corregirlo: es un comportamiento estándar de pip sobre Debian/Ubuntu moderno, el mismo que verías corriendo exactamente el mismo comando fuera de act, en cualquier contenedor Ubuntu 24.04+ reciente — no es específico de esta guía ni de catthehacker/ubuntu:act-latest.

Confundir el código de regla de ruff (F401) con un número de línea (de lectura). Qué pasa: alguien lee F401 y lo interpreta como "línea 401" en vez de como el identificador de una regla específica. Por qué pasa: ambos son números, y la salida de ruff los muestra cerca uno del otro. Cómo detectarlo: si buscas la línea 401 de un archivo de 5 líneas. Cómo corregirlo: la línea real está en la línea --> tracking_code.py:1:8 (archivo, línea 1, columna 8) — F401 es, en cambio, el código de la regla específica de Pyflakes para "import sin usar", útil para buscar esa regla exacta en la documentación de ruff o para silenciarla puntualmente si alguna vez hiciera falta (# noqa: F401, fuera del alcance de esta lección).

Pensar que este pipeline de tres steps "no cuenta" como CI/CD real por ser tan simple (de expectativa, cruza con la lección 6). Qué pasa: alguien, acostumbrado al ci.yml de nueve steps, concluye que app-ci.yml es "demasiado básico" para ser un ejemplo válido de CI/CD de aplicación. Por qué pasa: la complejidad de ci.yml (Terraform, LocalStack, tflocal) entrenó la expectativa de que un pipeline "real" necesita muchas piezas. Cómo detectarlo: si tu reacción a esta lección es "esto es muy simple para ser CI/CD de verdad". Cómo corregirlo: checkoutlinttest es, literalmente, el núcleo mínimo de cualquier pipeline de CI de aplicación real — un proyecto de producción le agregaría más pasos (build de una imagen, escaneo de seguridad, deploy), pero esos tres son el corazón que casi todos comparten. La simplicidad de este ejemplo es deliberada, para que el contraste con ci.yml sea nítido — no una versión "de juguete" del concepto.


Ejercicios

Ejercicio 1 — Cuenta los steps de memoria. Sin mirar esta lección, ¿cuántos steps tiene app-ci.yml, y cuántos tenía ci.yml del Módulo 3? ¿Qué tres herramientas explican la diferencia?

Ver solución

app-ci.yml: tres steps (checkout, lint, test). ci.yml: nueve steps. La diferencia la explican, principalmente, tres herramientas que ci.yml necesita y app-ci.yml no: Terraform (instalar, formatear, validar, planificar), awslocal/tflocal (hablar con LocalStack en vez de AWS real), y la publicación del plan como evidencia de revisión ($GITHUB_STEP_SUMMARY) — ninguna de las tres tiene equivalente en un pipeline de aplicación tan simple como el de esta lección.

Ejercicio 2 — Explica por qué --container-options no hizo falta aquí. En una o dos frases, explica a un colega por qué act push corrió sin el flag --container-options "--add-host=host.docker.internal:host-gateway" en esta lección, cuando fue obligatorio desde el Módulo 2 en adelante.

Ver solución

Ese flag existe, específicamente, para que el contenedor efímero que act crea para un job pueda resolver el nombre host.docker.internal y así hablar con LocalStack, que corre en el host, fuera de ese contenedor. app-ci.yml no tiene ningún step que necesite hablar con LocalStack ni con ningún otro servicio fuera del propio contenedor —pip install descarga de internet directamente, pytest corre completamente en memoria dentro del mismo contenedor—, así que no hay ninguna razón para que el job necesite resolver ese nombre de host en absoluto.

Ejercicio 3 — Diagnostica un fallo sin ver la salida completa. Un compañero te dice: "corrí act push sobre mi propia versión de app-ci.yml y el job falló, pero todavía no vi la palabra pytest en la salida". ¿En qué step falló, con certeza, y por qué puedes afirmarlo sin ver el mensaje de error exacto?

Ver solución

Falló en el step lint, con certeza. Los tres steps de este workflow corren en orden secuencial (checkoutlinttest), y si cualquiera de los primeros dos falla, act (igual que GitHub Actions real) detiene el job inmediatamente — nunca llega a correr el step siguiente. Si la palabra pytest nunca aparece en la salida, eso significa que el job nunca llegó al tercer step, así que el fallo tiene que haber ocurrido en checkout o en lint — y como checkout casi nunca falla salvo un problema serio de Git, lint es, con mucha certeza, el candidato real. Es exactamente el mismo razonamiento de "corte temprano" que ya usaste con ci.yml cuando fmt -check fallaba antes de llegar a plan.


Resumen y siguiente paso

En esta lección corriste, de verdad con act, el único pipeline de aplicación de todo este módulo: tres steps —checkout, lint, test— sobre un script Python trivial, sin ningún vínculo con Andes Cargo, sin Terraform, sin LocalStack. Viste la salida completa exitosa, y reprodujiste dos errores reales a propósito: el externally-managed-environment de PEP 668 al olvidar --break-system-packages, y un F401 de ruff al dejar un import sin usar. Confirmaste, con tus propios ojos y no solo en prosa, la tabla de contraste completa entre ci.yml (nueve steps, Terraform, LocalStack, un plan para revisión humana) y app-ci.yml (tres steps, pip install, un resultado binario decidido por la propia máquina).

Antes de avanzar deberías poder: escribir de memoria los tres steps mínimos de un pipeline de CI de aplicación; explicar por qué este pipeline no necesitó --container-options; y leer un error de ruff (código de regla, archivo, línea) sin confundirlo con un problema de Docker o de act.

La lección 8, el proyecto que cierra este módulo, no vuelve a este pipeline ni escribe más YAML — convierte todo lo que aprendiste en las ocho lecciones del módulo en un documento real: el ADR donde Andes Cargo justifica, por escrito, su elección de herramienta.

Recursos

  1. nektosact.com — User Guide — documentación oficial de act, la misma herramienta usada en toda esta guía, ahora sobre un pipeline de aplicación.
  2. GitHub — actions/checkout — la Action usada en el step checkout de esta lección, ya conocida desde el Módulo 2.
  3. Ruff — Documentation — documentación oficial del linter usado en el step lint, incluida la referencia de reglas como F401.
  4. pytest — Documentation — documentación oficial del framework de tests usado en el step test de esta lección.
  5. cicd-python-backend-guide (NIEVA) — donde un pipeline de aplicación como este se construye en profundidad, con build, más tipos de test, y despliegue real de un artefacto.