Módulo 4: La matriz — múltiples versiones y entornos
3. `strategy.matrix` de versiones de Python
Descripción
Ya sabes por qué querrías correr la suite de Reservo en varias versiones de Python: porque tu código puede tocar una diferencia entre 3.11 y 3.13 sin darse cuenta, y un solo job verde te lo escondería. Esta lección te enseña cómo se escribe eso en el YAML de GitHub Actions, y es más simple de lo que parece: unas cuantas líneas convierten un job en tres. La pieza se llama strategy.matrix, y es el corazón mecánico de todo el módulo.
Al terminar vas a poder escribir un workflow que declare una lista de versiones de Python —["3.11", "3.12", "3.13"]— y entender exactamente qué hace GitHub con ella: expandir un solo job en tres jobs idénticos, cada uno igual salvo la versión que instala. Vas a ver cómo se inyecta la versión de cada celda en el paso de setup-python con la sintaxis ${{ matrix.python-version }}, cómo se vería el log de CI con sus tres entradas de job, y —esto es lo que ancla todo— vas a correr la suite localmente en Python 3.14 como la celda-testigo: una de las corridas que la matriz haría, ejecutada de verdad para que veas su salida real. El YAML es contenido honesto (no hay runner aquí); pytest es real.
Conexión con el módulo: la lección 1 te dio el concepto de matriz y la 2 el catálogo de diferencias que la justifican. Esta es la primera de las lecciones "de construcción": escribes la dimensión de versión. La lección 4 le suma la dimensión de sistema operativo y verás cómo dos listas se multiplican en una cuadrícula. La 5 la afina con include/exclude/fail-fast. La 6 te enseña a leer los N resultados que esta matriz produce. Así que fija bien la mecánica de aquí —lista → N jobs, ${{ matrix.x }} inyecta el valor— porque todo lo demás la reutiliza.
Una receta con un ingrediente que cambia
Imagina que escribes una receta de pan y quieres publicarla probada con tres tipos de harina: de trigo, integral y de centeno. Podrías escribir la receta tres veces, copiando cada paso —amasar, reposar, hornear— y cambiando solo la línea de la harina. Tres recetas casi idénticas, y si mañana cambias el tiempo de horneado, tienes que corregirlo en las tres y rezar para no olvidar ninguna.
O puedes escribir la receta una vez, con un hueco donde dice la harina, y arriba una nota: "prepara esta receta con cada una de estas harinas: trigo, integral, centeno". Un lector diligente la ejecuta tres veces, rellenando el hueco con cada harina. Una sola receta, un solo lugar donde corregir el horneado, y tres panes probados.
strategy.matrix es exactamente esa segunda forma. Escribes el job una vez —checkout, instalar Python, instalar dependencias, correr pytest— con un hueco donde va la versión, y declaras arriba la lista de versiones. GitHub Actions es el lector diligente: toma tu único job, lo ejecuta una vez por cada versión de la lista, rellenando el hueco cada vez. No copias el job tres veces; lo escribes una vez y declaras el ingrediente que cambia. Si mañana agregas un paso, lo agregas en un solo lugar y las tres celdas lo heredan.
strategy.matrixtoma un job escrito una sola vez y una lista de valores, y genera un job por cada valor. La lista de versiones es el "ingrediente que cambia"; el resto del job es la receta compartida.
El YAML, pieza por pieza
Aquí está el workflow completo que corre la suite de Reservo en tres versiones de Python. Léelo entero primero; abajo lo desarmamos línea por línea. (Recuerda: esto es contenido —así se ve el workflow real—; no lo ejecuta un runner aquí, pero es exactamente lo que pondrías en tu repo.)
# .github/workflows/tests.yml
name: tests
on: [push, pull_request] # el workflow corre en cada push y cada PR
jobs:
test:
runs-on: ubuntu-latest # todas las celdas corren en Linux (por ahora)
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"] # <- la lista que se expande
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }} # <- el hueco que se rellena
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run the test suite
run: python -m pytest -v
Ahora las piezas que importan para la matriz. Lo demás —on, checkout, instalar dependencias, correr pytest— es la anatomía del workflow que viste en el módulo 2; aquí no cambia.
strategy: — abre el bloque de estrategia del job. Todo lo que tenga que ver con "cómo se multiplica este job" vive aquí dentro: la matriz, y más adelante (lección 5) fail-fast y max-parallel.
matrix: — declara la matriz propiamente dicha. Adentro pones una o más dimensiones, cada una una lista con nombre. Aquí hay una sola dimensión.
python-version: ["3.11", "3.12", "3.13"] — esta es la dimensión, y su nombre —python-version— lo eliges tú (podría llamarse py o version; el nombre es tuyo). El valor es una lista de tres strings. Aquí ocurre la magia: GitHub ve una lista de tres elementos y genera tres jobs, uno con python-version = "3.11", otro con "3.12", otro con "3.13". Ojo con las comillas: las versiones van como strings ("3.11", no 3.11), porque el YAML interpretaría 3.10 sin comillas como el número 3.1 —el 0 final se pierde— y acabarías instalando Python 3.1, que no existe. Comillas siempre.
${{ matrix.python-version }} — esta es la sintaxis para leer el valor de la celda actual. Dentro de cada job expandido, matrix.python-version vale la versión de esa celda. Aparece en dos lugares: en el name del paso (para que el log diga "Set up Python 3.12" y sepas cuál celda es) y —el que hace el trabajo— en python-version: de setup-python, donde le dice a la acción exactamente qué Python instalar. En la celda de 3.11 esa línea se resuelve a python-version: 3.11; en la de 3.13, a python-version: 3.13. El mismo YAML, tres resoluciones distintas.
Vale la pena verlo "expandido" mentalmente. GitHub toma tu único job test y lo convierte, por dentro, en algo equivalente a esto:
test (3.11) -> runs-on ubuntu-latest, instala Python 3.11, corre pytest
test (3.12) -> runs-on ubuntu-latest, instala Python 3.12, corre pytest
test (3.13) -> runs-on ubuntu-latest, instala Python 3.13, corre pytest
Tres jobs, idénticos salvo la versión, corriendo en paralelo (cada uno en su propio runner). Tú escribiste el job una vez; GitHub lo triplicó.
Cómo se ve el log de CI
Cuando esto corre en GitHub, no ves un log, ves tres, uno por celda. En la pestaña de la corrida aparece la lista de jobs, cada uno con su nombre entre paréntesis y su semáforo. Así se vería (esto es el formato honesto del log; no una captura de un runner que aquí no existe):
tests · push a main
✓ test (3.11) — 8 passed, 1 skipped in 0.4s
✓ test (3.12) — 8 passed, 1 skipped in 0.3s
✓ test (3.13) — 8 passed, 1 skipped in 0.3s
Fíjate en cómo GitHub nombra cada job: test (el nombre del job) más, entre paréntesis, el valor de la celda: test (3.11), test (3.12), test (3.13). Ese nombre es tu mapa: si mañana una celda se pone roja, el nombre te dice al instante en qué versión —test (3.11) en rojo significa "se rompió en 3.11 y solo ahí"—. La lección 6 se dedica entera a leer esto; por ahora quédate con que cada celda es un check con nombre propio.
Y mira el detalle del skipped: cada celda dice 8 passed, 1 skipped, pero —como vimos en las lecciones 1 y 2— el test que se salta es distinto en cada versión. En test (3.11) se salta la rama de itertools.batched; en test (3.12) y test (3.13) se salta el fallback manual. El conteo coincide, el detalle no, y por eso corres las tres: cada una ejercita una rama distinta del código.
La celda-testigo: correr una de verdad, en local
No tenemos un runner de GitHub, pero sí tenemos algo tan bueno para aprender: tu máquina es una celda de la matriz. Corre Python 3.14.0, así que hace de testigo de lo que la celda test (3.14) haría —o, en espíritu, de cualquier celda ≥ 3.12, porque el camino de código es el mismo—. Correr la suite en local es ejecutar de verdad lo que el paso Run the test suite del YAML haría en esa celda. Hagámoslo con -v para ver cada test, tal como el YAML pide (python -m pytest -v):
python -m pytest -v tests/
Qué esperar. En Python 3.14.0 con pytest 9.1.1, medido ejecutando de verdad:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m4/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m4
collected 9 items
tests/test_pricing.py::test_basic_three_hours PASSED [ 11%]
tests/test_pricing.py::test_pro_three_hours PASSED [ 22%]
tests/test_pricing.py::test_basic_one_hour PASSED [ 33%]
tests/test_refunds.py::test_full_refund_72h_before PASSED [ 44%]
tests/test_refunds.py::test_half_refund_36h_before PASSED [ 55%]
tests/test_refunds.py::test_no_refund_12h_before PASSED [ 66%]
tests/test_version_features.py::test_report_pages_groups_bookings PASSED [ 77%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 88%]
tests/test_version_features.py::test_report_pages_manual_fallback_on_old_python SKIPPED [100%]
========================= 8 passed, 1 skipped in 0.01s =========================
Lee la primera línea de datos: platform darwin -- Python 3.14.0. Esa línea es la que te dice en qué celda estás parado. En el CI, la celda test (3.11) empezaría su log con Python 3.11.x, la de test (3.12) con Python 3.12.x, y así. Cuando reproduzcas un fallo (módulo 3) o leas un resultado (lección 6), esa línea es lo primero que miras: te dice si el log que tienes enfrente es de la celda que crees.
Y el resumen —8 passed, 1 skipped— es exactamente lo que la celda test (3.14) reportaría en el log de CI. La corrida local no es una simulación aproximada: es la misma orden (python -m pytest -v) contra el mismo código, con la única diferencia de que el runner de GitHub la correría además en 3.11, 3.12 y 3.13. Por eso decimos que tu máquina es la celda-testigo: te muestra, de verdad, qué haría una de las N corridas de la matriz.
Elegir qué versiones poner en la lista
Una duda razonable: ¿por qué ["3.11", "3.12", "3.13"] y no otra cosa? La lista no se saca de un sombrero; se deriva de una pregunta: ¿qué versiones prometes soportar? Ese es el tema completo de la lección 7, pero aquí va el criterio mínimo para que tu YAML no sea arbitrario:
- Incluye tu versión mínima soportada. Si tu README dice "Python 3.11+", entonces 3.11 tiene que estar en la lista, porque es la más propensa a que falte una función nueva que usaste sin querer. Es la celda que caza los
ImportErrorde la lección 2. - Incluye la más nueva estable. Para saber que tu código no se rompe con lo último (comportamiento que cambió, deprecaciones). Al momento de escribir esto sería 3.13 o 3.14.
- Considera las intermedias. Si soportas de 3.11 a 3.13, poner también 3.12 cuesta una celda más y cierra el hueco. Para una librería, se ponen todas; para algo más chico, a veces basta la mínima y la máxima.
Lo que no debes hacer es poner versiones que no soportas "por si acaso": cada una es una celda que consume minutos y que, si se pone roja, te obliga a arreglar algo que nadie te pidió soportar. La lista de la matriz es una promesa: "prometo que esto funciona en estas versiones". No prometas de más.
Errores comunes
Escribir las versiones sin comillas. Qué pasa: pones python-version: [3.10, 3.11, 3.12] sin comillas. El YAML lee 3.10 como el número 3.1 (el cero final de un número se pierde), así que la celda intenta instalar "Python 3.1", que no existe, y el job falla en setup-python con un error raro. Por qué pasa: en YAML, un valor sin comillas que parece número se interpreta como número, y 3.10 == 3.1. Cómo detectarlo: si una celda falla instalando Python con un mensaje de "versión no encontrada" y tu lista no tiene comillas, es esto. Cómo corregirlo: siempre comillas en las versiones: ["3.10", "3.11", "3.12"]. El 0 sobrevive porque ahora es un string, no un número.
Olvidar inyectar ${{ matrix.python-version }} en setup-python. Qué pasa: declaras la matriz de tres versiones, pero en el paso de setup-python dejas python-version: "3.12" fijo (o lo omites). Las tres celdas se generan, pero las tres instalan la misma versión, así que estás corriendo la misma prueba tres veces y creyendo que probaste tres versiones. Por qué pasa: la matriz genera las celdas, pero tú tienes que conectar el valor de la celda al paso que lo usa; si no, la dimensión no llega a ningún lado. Cómo detectarlo: mira la línea Python 3.x en los logs de las tres celdas; si dicen la misma versión, no conectaste la matriz. Cómo corregirlo: python-version: ${{ matrix.python-version }} en setup-python, para que cada celda instale su versión.
Poner en la matriz versiones que no soportas. Qué pasa: agregas 3.9 y 3.10 "para estar seguro", aunque tu proyecto solo promete 3.11+. Ahora esas celdas fallan (usas features de 3.11) y alguien pierde tiempo arreglando soporte para versiones que nadie pidió, o —peor— las silencian con skipif y la matriz se vuelve ruido. Por qué pasa: "más versiones se siente más completo". Cómo detectarlo: por cada versión de la matriz pregúntate "¿prometo esto en mi README?". Si no, sobra. Cómo corregirlo: la lista de la matriz = las versiones que prometes soportar, ni una más. La lección 7 formaliza este criterio.
Ejercicios
Ejercicio 1 — Expande la matriz a mano. Dado este fragmento de YAML, escribe los nombres de los jobs que GitHub generaría y con qué versión instalaría cada uno:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
Ver solución
Una lista de cuatro versiones genera cuatro jobs, uno por elemento:
test (3.10) -> instala Python 3.10, corre pytest
test (3.11) -> instala Python 3.11, corre pytest
test (3.12) -> instala Python 3.12, corre pytest
test (3.13) -> instala Python 3.13, corre pytest
El nombre de cada job es el nombre del job base (test) más el valor de la celda entre paréntesis. Cada uno corre en paralelo, idéntico salvo la versión que setup-python instala gracias a ${{ matrix.python-version }}. La regla mecánica: N elementos en la lista → N jobs. Cuatro versiones son cuatro corridas de tu suite en cada push — dato que importará cuando hablemos de costo en la lección 7.
Ejercicio 2 — Caza el bug del YAML. Un compañero declara la matriz así y se queja de que "las tres celdas corren Python 3.1 y fallan". ¿Cuál es el bug y cómo se arregla?
strategy:
matrix:
python-version: [3.10, 3.11, 3.12]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
Ver solución
El bug son las comillas que faltan en la lista de versiones. En YAML, 3.10 sin comillas se interpreta como el número 3.1 (el 0 final de un número decimal no aporta y se descarta), y lo mismo pasaría con cualquier versión .X0. Así, la celda que debía ser 3.10 le pide a setup-python la versión 3.1, que no existe, y falla. (Las celdas de 3.11 y 3.12 sobreviven por casualidad, porque 3.11 y 3.12 como números no pierden dígitos — pero es pura suerte, y 3.10, 3.20, etc. sí se rompen.)
El arreglo es poner cada versión como string:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
Con comillas, "3.10" es el texto 3.10 y el 0 sobrevive intacto. Regla de oro: las versiones de Python en la matriz van siempre entre comillas. El paso de setup-python estaba bien; el problema era solo la lista.
Ejercicio 3 — De la promesa a la lista. El README de un proyecto dice: "Compatible con Python 3.11, 3.12 y 3.13. Probado en la última versión estable." No usa ninguna feature exclusiva de 3.13. Escribe la sección strategy.matrix que corresponde a esa promesa, y justifica en una frase por qué incluyes (o no) cada versión.
Ver solución
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
Justificación por versión:
- 3.11 — es la mínima que el README promete; tiene que estar, porque es la celda más propensa a delatar el uso accidental de una función que solo existe desde 3.12+ (un
ImportErrorcomo el de la lección 2). - 3.12 — es una versión intermedia prometida; incluirla cuesta una celda y cierra el hueco entre la mínima y la máxima, cazando comportamientos que cambiaron justo ahí.
- 3.13 — es la más nueva estable y la promesa explícita "probado en la última versión estable"; atrapa deprecaciones y cambios de comportamiento de lo último.
No se incluye 3.10 (el README no lo promete) ni 3.14 (aún no es la estable en el momento de la promesa; se agregaría cuando lo sea). La lista es la promesa del README, traducida a YAML: ni una versión de más, ni una de menos.
Resumen y siguiente paso
En esta lección escribiste la primera dimensión de una matriz: strategy.matrix con una lista de versiones de Python. Viste que una lista de tres versiones —["3.11", "3.12", "3.13"], siempre entre comillas para que el 0 no se pierda— hace que GitHub genere tres jobs, idénticos salvo la versión, corriendo en paralelo. La conexión clave es ${{ matrix.python-version }}: la sintaxis que lleva el valor de cada celda al paso de setup-python, para que cada job instale su Python. Sin esa inyección, la matriz genera celdas pero todas corren la misma versión.
Viste cómo se nombra cada job en el log de CI —test (3.11), test (3.12), test (3.13)—, cómo ese nombre es tu mapa para localizar en qué versión falló algo, y cómo cada celda reporta 8 passed, 1 skipped con el detalle de que el test que se salta cambia por versión. Y ejecutaste de verdad la celda-testigo: la suite en Python 3.14.0, 8 passed, 1 skipped, la misma orden que el paso Run the test suite correría en cada celda, con la línea Python 3.14.0 que te ancla a qué celda estás mirando.
Antes de avanzar deberías poder: escribir un strategy.matrix de versiones a partir de una promesa de soporte; explicar qué genera una lista de N versiones; decir qué hace ${{ matrix.python-version }} y dónde va; y detectar el bug de las comillas faltantes.
Lo que sigue, en la lección 4, es agregar la segunda dimensión: el sistema operativo. Vas a ver os: [ubuntu-latest, macos-latest, windows-latest], cómo dos listas se multiplican en una cuadrícula (3 versiones × 3 sistemas = 9 celdas), y qué diferencias reales entre sistemas —con valores medidos en macOS— justifican encender esa segunda dimensión.
Recursos
- Using a matrix for your jobs — GitHub Actions — la referencia oficial de
strategy.matrix. Lee la sección "Using a single-dimension matrix": es exactamente lo que escribimos aquí, una sola lista que se expande en N jobs. actions/setup-python— la acción que instala la versión de Python de cada celda. Su README muestra el patrónpython-version: ${{ matrix.python-version }}y explica qué formatos de versión acepta (incluido por qué conviene el string entre comillas).- Workflow syntax:
jobs.<job_id>.strategy— GitHub Actions — la definición formal del bloquestrategy, donde viven la matriz,fail-fast(lección 5) ymax-parallel. Útil como referencia cuando quieras el nombre exacto de una clave. python -m pytest— invocación desde la línea de comandos — pytest — la forma de correr pytest que usa el pasoRun the test suitedel workflow. Vale la pena saber por quépython -m pytest(con el-m) es más robusto en CI que llamarpytesta secas.