Módulo 8: Proyecto — un pipeline de CI para Reservo
2. El workflow base que corre la suite
Descripción
Todo pipeline, por sofisticado que llegue a ser, empieza con la misma pregunta humilde: ¿cómo hago que mi suite corra sola en cada cambio? La respuesta es un workflow mínimo —traer el código, instalar Python, instalar dependencias, correr pytest— y esa es la capa que armamos aquí, la primera de las seis que el capstone teje. Es el piso. Sin esta capa no hay pipeline: la matriz no tiene qué multiplicar, el caché no tiene qué acelerar, la puerta no tiene qué vigilar. Todo lo demás se apila encima de estos cuatro pasos.
Ya montaste este workflow en el módulo 2, y no lo vamos a reaprender desde cero. Lo que hacemos aquí es distinto: lo miramos como la capa base de un conjunto, entendiendo qué responsabilidad tiene y dónde termina, para que las próximas cinco lecciones sepan exactamente qué le agregan. Vas a escribir el tests.yml en su forma más simple que funciona, correr su paridad local —los mismos comandos en tu terminal, con la salida real— y confirmar que el esqueleto verde de Reservo funciona antes de complicarlo. Un pipeline que arranca simple y crece con intención es robusto; uno que nace enorme y copiado es una caja negra.
Al terminar vas a tener el piso del pipeline en su sitio y comprobado: el workflow base escrito y explicado step por step, la paridad local ejecutada (13 passed, 1 skipped, exit code 0), y la comprensión de por qué este es el esqueleto sobre el que todo se apoya —y qué, deliberadamente, todavía no hace—.
Conexión con el módulo: esta lección abre el recorrido que arma el pipeline pieza por pieza. La lección 1 te dio el plano completo; aquí colocas la primera capa. La 3 le sumará la reproducibilidad (dependencias pinneadas), la 4 la matriz de versiones, la 5 la velocidad, la 6 la puerta de cobertura, la 7 la política de flaky. Cada una toma este workflow base y le agrega su etapa. Por eso importa entenderlo como piso: cuando en la lección 4 metamos strategy.matrix, vas a ver que envuelve exactamente estos steps; cuando en la 6 agreguemos --cov-fail-under, vas a ver que modifica exactamente este pytest. El resto del módulo es este esqueleto, engordado con intención.
Los cimientos antes que los pisos
Piensa en construir un edificio. Nadie empieza por el penthouse. Se empieza por los cimientos: una losa nivelada, plomada, capaz de sostener lo que venga encima. Los cimientos no son glamorosos —nadie fotografía una losa—, pero si están mal, cada piso que agregues amplifica el error, y a la altura del quinto ya nada cuadra. El constructor serio invierte en los cimientos precisamente porque todo lo demás descansa ahí.
El workflow base es la losa de tu pipeline. No tiene matriz, ni caché, ni puerta de cobertura; hace lo mínimo —corre la suite en cada push— pero lo hace bien: el código llega al runner, el Python correcto se instala, las dependencias entran, pytest corre y su veredicto pinta el job. Cuando en las próximas lecciones apiles la matriz encima, o el caché, o la puerta, todas esas capas van a descansar sobre estos cuatro steps. Si el piso está torcido —falta el checkout, la versión mal escrita—, cada capa que agregues heredará el problema. Por eso empezamos aquí, lo dejamos plomado, y solo entonces subimos.
El workflow base es la losa del pipeline: los cuatro steps —checkout, setup-python, install, pytest— sobre los que descansan todas las etapas siguientes. Simple a propósito, plomado a propósito, porque todo lo demás se apila encima.
El workflow base, step por step
Aquí está el piso del pipeline de Reservo. Cuatro steps, catorce líneas de contenido:
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run the test suite
run: python -m pytest
Repasa cada decisión —todas del módulo 2, pero ahora leídas como la base de un conjunto que va a crecer:
name: tests— la etiqueta del workflow en la pestaña Actions. Descriptiva y estable, para que el badge y el historial siempre digan lo mismo.on: [push, pull_request]— los dos disparadores estándar.pushda feedback en cada cambio que subes a tu rama;pull_requestpone el guardián en cada solicitud de merge haciamain. Los dos timbres del pipeline.runs-on: ubuntu-latest— una máquina Linux limpia, barata y suficiente para lógica de Python puro. (En la lección 4, cuando llegue la matriz, este valor puede volverse${{ matrix.os }}; por ahora, una sola fila.)Check out the codeconactions/checkout@v5— primero de todo, porque nada se puede hacer sin el código en el runner. Sin este step,pip install -r requirements.txtno encontraría el archivo ypytestno hallaría tests.Set up Pythonconpython-version: "3.14"— la versión de la guía, entre comillas para que YAML no la lea como el número 3.14 y pierda precisión. Va antes de instalar, para que pip y pytest corran sobre este Python. (En la lección 4 esta línea se vuelve${{ matrix.python-version }}.)Install dependencies— el|agrupa dos comandos: actualizar pip (higiene) e instalar desderequirements.txt. (En la lección 3 cambiaremos arequirements-dev.txtpara traer las herramientas de CI; en la 5, este step ganará el caché.)Run the test suiteconpython -m pytest— el corazón: descubre y corre los catorce tests, y su exit code pinta el job. (En la lección 5 ganará-n auto; en la 6,--cov-fail-under.)
Cada paréntesis que acabo de escribir es una promesa de las próximas lecciones. Ese es el punto de mirar el workflow como base: cada step tiene un lugar donde una etapa futura se enganchará. El requirements.txt que este workflow instala, por ahora, es una línea —Reservo es stdlib pura y solo necesita pytest para probarse—:
# requirements.txt
pytest==9.1.1
La paridad local: corre el piso en tu terminal
La idea que sostiene toda la guía: lo que el CI le hace a tu suite es lo mismo que le hace tu máquina. Puedes comprobarlo ejecutando, en tu terminal, los mismos steps que el workflow ejecutaría en el runner. Si obtienes la misma salida verde, tienes evidencia directa de que el pipeline hará lo correcto. Recorramos los steps del workflow base, uno por uno, en local. Cada comando se ejecutó de verdad con Python 3.14.0 y pytest 9.1.1.
El step Check out the code en local es, simplemente, estar parado en tu proyecto ya clonado. El runner trae el código; tú ya lo tienes. No hay comando que correr.
El step Set up Python en local es tener activo el Python correcto, en un entorno virtual limpio:
python3.14 -m venv .venv
source .venv/bin/activate
python --version
Python 3.14.0
Mismo Python que setup-python: "3.14" dejaría en el runner. Paridad en la versión: confirmada.
El step Install dependencies en local son los dos mismos comandos del run::
python -m pip install --upgrade pip
pip install -r requirements.txt
Instala pytest y sus dependencias transitivas; las versiones exactas las veremos como foto en la lección 3.
El step Run the test suite en local es el pytest del workflow:
python -m pytest
Qué esperar (salida real de la suite de Reservo, con la salida en puntos que pytest usa por defecto):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m8
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0, rerunfailures-16.4, cov-7.1.0
collected 14 items
tests/test_availability.py .... [ 28%]
tests/test_pricing.py .... [ 57%]
tests/test_refunds.py ... [ 78%]
tests/test_version_features.py ..s [100%]
======================== 13 passed, 1 skipped in 0.02s =========================
Lee la salida despacio. Cada punto es un test que pasó; la s al final de test_version_features.py es el test que se saltó (el fallback manual, que en 3.14 no aplica). El resumen —13 passed, 1 skipped— es idéntico al que verías en el runner, salvo la línea platform: aquí darwin (macOS), en el runner sería linux (Ubuntu). Todo lo demás —Python 3.14.0, pytest 9.1.1, collected 14 items, el conteo final— coincide, porque es el mismo comando sobre la misma suite con las mismas dependencias.
El exit code: cómo pytest pinta el job
Un runner no "lee" la salida de pytest para decidir si el job pasa; lee su exit code, el número que todo programa de línea de comandos devuelve al terminar. Por convención universal, 0 significa éxito y cualquier número distinto de 0 significa fallo. pytest respeta esa convención: si todos los tests pasan (o se saltan), devuelve 0; si alguno falla, devuelve 1. El runner corre python -m pytest, mira el número, y pinta el step verde si es 0 o rojo si no. Compruébalo en local:
python -m pytest -q > /dev/null; echo "exit code: $?"
exit code: 0
$? es la variable del shell que guarda el exit code del último comando; > /dev/null esconde la salida para ver solo el número. 0: en el runner, ese cero pintaría el step Run the test suite de verde con un ✓, y el job entero de verde. Un 1 skipped no altera el 0 —un skip no es un fallo, es un tercer estado—, así que la suite de Reservo, con su test saltado, sigue dando exit 0 y job verde.
Para ver el otro lado —que el piso muerde cuando debe—, rompe una regla de Reservo a propósito y corre de nuevo. Si cambiaras el descuento pro del 20% al 25% en reservo/pricing.py, test_pro_three_hours esperaría 6000 y recibiría 5625, y verías:
=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_pro_three_hours - assert 5625 == 6000
========================= 1 failed, 13 passed in 0.02s =========================
python -m pytest -q > /dev/null 2>&1; echo "exit code: $?"
exit code: 1
Exit code 1: en el runner, ese uno pintaría el step de rojo con un ✗ y el job entero de rojo, y —con protección de rama— bloquearía el merge. Restauras el código (vuelve al 20%) y el exit code regresa a 0. Este ciclo —verde da 0, rojo da 1, restauro, vuelve a 0— es la máquina del CI comprobada en tu propia terminal. Un pipeline que nunca viste ponerse rojo es uno en el que no deberías confiar del todo.
Qué hace el piso, y qué deliberadamente no
Ser honesto sobre el alcance de esta capa es parte del oficio, y es lo que hace que las próximas lecciones tengan sentido. El workflow base hace: corre la suite completa de Reservo en cada push y pull request, sobre Python 3.14 en Ubuntu, instalando desde requirements.txt, y pinta el job según el exit code. Eso ya es un salto enorme —de "corro los tests cuando me acuerdo" a "se corren solos en cada cambio"—.
Lo que deliberadamente no hace todavía, y qué lección lo agrega:
- No fija las versiones exactas de todo lo que instala más allá de pytest, ni garantiza que el runner y tu máquina instalen idéntico → lección 3 (dependencias pinneadas, reproducibilidad).
- No prueba en más de una versión de Python → lección 4 (la matriz).
- No cachea nada, así que baja las dependencias de internet en cada corrida, ni corre los tests en paralelo → lección 5 (caché y velocidad).
- No exige un mínimo de cobertura; un cambio que llega sin tests pasa igual → lección 6 (la puerta de cobertura).
- No tiene política para un test que falla de forma intermitente → lección 7 (flaky).
Fíjate en lo que hace esta lista: no es una confesión de que el piso está "incompleto", sino el mapa de construcción. Cada punto es una capa que va a descansar sobre estos cuatro steps. El piso es el mínimo que funciona, y está bien que lo sea por ahora, porque cada mejora tiene su lección y su justificación. Un pipeline que se presenta como "completo" siendo el básico engaña; uno que dice "hago esto, dejé aquello para después, por esta razón" es confiable y deja claro el camino.
Errores comunes
Copiar un workflow "completo" y saltarse el piso. Qué pasa: alguien, ansioso por tener "CI de verdad", pega un tests.yml con matriz, caché y puerta de cobertura de un tutorial, sin haber entendido nunca los cuatro steps base. El día que el pipeline falla por una razón de infraestructura —un checkout ausente, una versión mal escrita— no sabe ni por dónde empezar, porque nunca vio el esqueleto solo. Por qué pasa: empezar por lo elaborado se siente más productivo que empezar por lo humilde. Cómo detectarlo: si no puedes explicar qué hace cada uno de los cuatro steps base y qué pasaría sin él, construiste sobre cimientos que no entiendes. Cómo corregirlo: escribe y corre el piso primero, comprueba su paridad local, y solo entonces apila. Este módulo lo hace en ese orden a propósito.
Olvidar el checkout (el step invisible). Qué pasa: el workflow tiene setup-python, install y pytest, pero le falta actions/checkout. El código nunca llega al runner, así que pip install -r requirements.txt no encuentra el archivo y pytest no halla tests (exit code 5). Por qué pasa: en local el código "ya está ahí", así que es fácil olvidar que en el runner hay que traerlo explícitamente. Cómo detectarlo: si el log del CI dice que no encuentra archivos que en tu máquina sí existen, sospecha del checkout. Cómo corregirlo: el checkout va siempre primero, antes que cualquier step que toque el código. Es el step que convierte un runner vacío en tu proyecto.
No comprobar que el piso muerde. Qué pasa: alguien monta el workflow base, lo ve verde una vez, y confía. Nunca comprueba qué pasa cuando un test falla, así que no sabe si el pipeline de verdad se pondría rojo —quizá un error de configuración hace que pytest no encuentre los tests y devuelva verde por vacío—. Por qué pasa: ver verde una vez da una falsa sensación de seguridad. Cómo detectarlo: si nunca provocaste un rojo a propósito, no sabes si tu pipeline distingue "todo bien" de "no probé nada". Cómo corregirlo: rompe una regla de Reservo, confirma el exit code 1 y el job rojo, y restaura. El ciclo verde→rojo→verde es la prueba de que el piso reacciona a lo que debe.
Ejercicios
Ejercicio 1 — Ordena los steps y justifica. Un compañero escribió los cuatro steps del workflow base pero en desorden: (A) pytest, (B) setup-python, (C) checkout, (D) pip install. Ponlos en el orden correcto y explica en una frase por qué cada uno va donde va.
Ver solución
El orden correcto es C → B → D → A: checkout, setup-python, pip install, pytest.
- (C)
checkoutprimero — nada se puede hacer sin el código en el runner; los tres steps que siguen lo necesitan presente. - (B)
setup-pythonsegundo — hay que tener el Python correcto instalado antes de usar pip o pytest, que corren sobre ese Python. - (D)
pip installtercero — las dependencias (pytest, y en la lección 3 las herramientas de CI) tienen que estar instaladas antes de correr la suite. - (A)
pytestúltimo — el corazón: solo tiene sentido cuando el código está presente, Python instalado y las dependencias en su lugar.
La regla mecánica: cada step prepara el terreno para los que siguen. Un orden equivocado rompe la cadena —pytest antes de pip install no encontraría pytest; cualquier cosa antes de checkout no encontraría el código—.
Ejercicio 2 — La versión sin comillas. Un compañero escribió python-version: 3.14 sin comillas y su workflow "instala una versión rara de Python". Explica qué pasa y por qué las comillas lo arreglan.
Ver solución
Sin comillas, YAML lee 3.14 como un número de punto flotante, y 3.14 como float es exactamente 3.14. Hasta ahí parece inofensivo, pero el problema clásico es con versiones como 3.10: YAML las lee como el float 3.1 (el cero final de un número no significa nada), así que setup-python recibe "3.1" y busca Python 3.1 —una versión de hace más de una década que no existe en el runner— y falla. Con 3.14 el riesgo es menor, pero la regla es la misma: una versión es una cadena de texto, no un número, y debe escribirse entre comillas: python-version: "3.14".
Las comillas le dicen a YAML "esto es texto literal, no lo interpretes como número", así que "3.10" se mantiene como "3.10" y "3.14" como "3.14", y setup-python recibe exactamente lo que escribiste. La lección práctica: siempre entre comillas las versiones en el YAML, aunque en tu caso particular parezca que funciona sin ellas —el día que uses una versión terminada en cero, el bug aparece—.
Ejercicio 3 — Escribe la nota de alcance del piso. Acabas de montar el workflow base de Reservo. Escribe una nota de alcance de tres o cuatro líneas: qué hace tu pipeline hoy y qué queda, a propósito, para las lecciones siguientes. (Este es el hábito que la lección 1 pedía en el segundo entregable, aplicado a la capa base.)
Ver solución
Una nota razonable:
Alcance de
tests.yml(capa base). Corre la suite completa de Reservo (14 tests: precios, reembolsos, disponibilidad y la feature de versión) en cada push y pull request, sobre Python 3.14 en Ubuntu, instalando desderequirements.txt. El job se pone rojo si algún test falla (exit code ≠ 0); un test saltado no rompe el build. Queda para las lecciones siguientes: fijar todas las versiones para instalaciones deterministas (lección 3); correr en 3.11/3.12/3.13 con una matriz (lección 4); cachear dependencias y paralelizar con-n auto(lección 5); exigir un umbral de cobertura que rompa el build (lección 6); y definir la política de flaky (lección 7). Este pipeline es el piso: el mínimo que funciona, listo para crecer con intención.
Lo importante de la nota: reconoce con honestidad que el piso es el más simple que funciona, dice qué protege ya (los 14 tests en cada cambio) y enumera qué falta con el número de lección que lo agrega. No es una disculpa por estar incompleto; es el mapa de las cinco capas que faltan. Una buena nota de alcance evoluciona con el pipeline: en cada lección tacharás una línea de "queda para después" y la moverás a "hace".
Resumen y siguiente paso
En esta lección colocaste la primera capa del pipeline: el workflow base que corre la suite, la losa sobre la que se apilan las otras cinco etapas. Escribiste el tests.yml en su forma mínima que funciona —on: [push, pull_request], runs-on: ubuntu-latest, los cuatro steps checkout, setup-python, install y pytest—, y justificaste cada decisión leyéndola como base de un conjunto que va a crecer: cada step tiene un lugar donde una etapa futura se enganchará.
Comprobaste la paridad local ejecutando los mismos steps en tu terminal: Python 3.14.0 confirmado, dependencias instaladas, y la suite en verde —13 passed, 1 skipped, exit code 0—, con la única diferencia esperable en la línea platform. Viste cómo el exit code pinta el job (0 verde, 1 rojo) y comprobaste que el piso muerde provocando un rojo real y restaurándolo. Y escribiste la nota de alcance: qué hace el piso y qué deja, a propósito, para las capas siguientes.
Antes de avanzar deberías poder: escribir de memoria los cuatro steps del workflow base en el orden correcto y justificar cada uno; correr la paridad local de tu suite y leer su salida; explicar cómo el exit code de pytest se traduce en el color del job; y redactar una nota de alcance honesta de tu capa base.
Lo que sigue, en la lección 3, es la primera capa que apilamos encima: las dependencias pinneadas y la reproducibilidad. Hasta ahora tu requirements.txt fija pytest, pero el resto de lo que se instala —las dependencias transitivas, y en la lección 5 las herramientas de CI— puede variar entre tu máquina y el runner, y esa variación es la causa clásica del "en mi máquina funciona" con el CI en rojo. Vas a aprender a fijar todo lo que el pipeline instala, con una foto exacta de versiones, para que el runner instale idéntico a ti y el piso que acabas de montar sea, además de automático, reproducible.
Recursos
- Building and testing Python — GitHub Actions — la guía oficial con el workflow de Python de ejemplo, idéntico en estructura al piso que montaste. El primer lugar a consultar cuando armes el CI base de un proyecto real.
actions/checkout— la acción que trae tu código al runner, el primer step y el más fácil de olvidar. Su README explica por qué va primero y qué pasa sin él.actions/setup-python— la acción que instala la versión de Python; su documentación detalla los formatos de versión aceptados y por qué van entre comillas. En la lección 5 volveremos a ella por su opción de caché.- How to invoke pytest — documentación de pytest — las formas de correr la suite y, sobre todo, la sección de exit codes que conecta el resultado de pytest con el color del job. La referencia que explica el 0 y el 1 que viste en la paridad local.