Cambiar tema

Matrix de GitHub Actions: guía práctica de pruebas paralelas multiplataforma y multiversión

Easton editorial illustration: single workflow definition card, OS-by-version matrix hinge, parallel test lanes, completion collector

El año pasado un proyecto open source me contactó: su archivo de configuración de CI ya superaba las 800 líneas. Lo abrí y estaba lleno de definiciones de jobs repetidas — Node 16 en Ubuntu, Node 16 en Windows, Node 16 en macOS… y luego lo mismo con Node 18 y Node 20. ¿Cambiar un comando de test? Había que tocar 12 sitios. ¿Añadir una versión nueva? Copiar y pegar durante un cuarto de hora.

En ese momento me di cuenta de que mucha gente sigue manteniendo pruebas multiplataforma y multiversión a mano.

La función Matrix de GitHub Actions, en esencia, te ayuda a expandir automáticamente esas configuraciones repetidas. Defines unos sistemas operativos y unas versiones de runtime, y la plataforma ejecuta todas las combinaciones por ti. Suena sencillo, pero al usarla de verdad aparecen bastantes trampas: explosión de combinaciones que dispara la factura, un fallo que tumba toda la cadena, no saber cómo excluir combinaciones concretas… Yo me he topado con todas.

Este artículo empieza por la sintaxis básica de Matrix y te lleva paso a paso por exclude/include para un control preciso, la elección de estrategia fail-fast, la limitación de concurrencia con max-parallel y la generación dinámica de Matrix. Al final tienes 5 plantillas de workflow listas para copiar en tu proyecto, desde proyectos personales hasta escenarios empresariales.

2. Conceptos clave de Matrix: expandir múltiples tareas con un clic

La lógica central de Matrix es simple: defines varias dimensiones y GitHub Actions hace automáticamente el producto cartesiano.

Por ejemplo, tu proyecto debe probarse en Ubuntu, Windows y macOS, y ser compatible con Node.js 18, 20 y 22. Con el enfoque tradicional escribirías 9 jobs a mano, repitiendo entorno, instalación y comandos de test. Con Matrix basta con esto:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

runs-on: ${{ matrix.os }}

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
  - run: npm test

Estas 10 líneas se expanden en 3 x 3 = 9 tareas en paralelo. Cada tarea recibe distintos valores de matrix.os y matrix.node y ejecuta todas las combinaciones.

El proyecto de 800 líneas que mencioné quedó en unos 120 tras refactorizar con Matrix — más del 60% menos de código. El mantenimiento también baja: añadir una versión nueva es meter un número en el array, sin copiar montones de jobs.

Lo que Matrix puede hacer por ti:

  • Generar combinaciones de prueba multiplataforma y multiversión con un clic
  • Expandir toda la configuración automáticamente y evitar código repetido
  • Excluir combinaciones problemáticas con exclude
  • Añadir casos con configuración especial mediante include
  • Controlar la concurrencia para equilibrar velocidad y coste

Lo que no resuelve:

  • Tests mal escritos — Matrix no los arregla
  • Demasiadas combinaciones y factura descontrolada — debes limitar tú las dimensiones
  • Instalación lenta de dependencias — hace falta estrategia de caché

La verdad es que Matrix no cuesta entenderlo; lo difícil es usarlo para problemas reales de ingeniería. Empecemos por la sintaxis básica.

3. Sintaxis básica: el principio de combinación os x versión

Las reglas de combinación de Matrix son el producto cartesiano de las matemáticas. Cada dimensión se combina con todas las demás.

Una dimensión, N valores -> N tareas

Dos dimensiones, M x N valores -> M x N tareas

Tres dimensiones, A x B x C valores -> A x B x C tareas

Un ejemplo concreto. Tienes un proyecto Python que debe probarse en Linux y Windows con Python 3.9, 3.10, 3.11 y 3.12, y además con PostgreSQL y MySQL:

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.9', '3.10', '3.11', '3.12']
        database: [postgresql, mysql]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Setup ${{ matrix.database }}
        run: |
          # Iniciar el servicio de base de datos correspondiente
          if [ "${{ matrix.database }}" = "postgresql" ]; then
            docker run -d -p 5432:5432 postgres
          else
            docker run -d -p 3306:3306 mysql
          fi
        shell: bash
      - run: pip install -r requirements.txt
      - run: pytest

Esta configuración genera 2 x 4 x 2 = 16 tareas. Cada una corre en un entorno independiente, sin interferencias.

Formas de acceder a las variables de Matrix:

  • ${{ matrix.os }} — sistema operativo de la tarea actual
  • ${{ matrix.python-version }} — versión de Python de la tarea actual
  • ${{ matrix.database }} — tipo de base de datos de la tarea actual

Estas variables puedes usarlas en runs-on, steps, env, etc., para ajustar dinámicamente el comportamiento de cada tarea.

Una trampa habitual: mucha gente cree que Matrix resuelve solo la instalación de dependencias. En realidad cada tarea es un entorno independiente y la instalación se repite. Si instalar dependencias tarda 2 minutos, 16 tareas implican 32 minutos de espera (suponiendo ejecución en serie).

Hay dos soluciones:

  1. Usar caché — cachear directorios de pip o npm y evitar descargas repetidas
  2. Reducir combinaciones — excluir pruebas innecesarias con exclude

La estrategia de caché la expliqué en detalle en [Estrategia de caché en GitHub Actions: acelera tu pipeline CI/CD 5 veces]; aquí nos centramos en exclude/include para controlar con precisión las combinaciones de prueba.

4. exclude/include: control preciso de las combinaciones de prueba

Matrix hace por defecto todas las permutaciones de las dimensiones, pero en proyectos reales a menudo hay combinaciones que no hace falta probar o que requieren un tratamiento especial.

4.1 exclude: excluir combinaciones inválidas

Mantuve un proyecto Python donde Windows + Python 3.9 fallaba siempre: una dependencia tenía problemas de compatibilidad en Windows con 3.9. El proyecto apuntaba sobre todo a servidores Linux; Windows era soporte secundario, no merecía arreglar ese bug concreto.

Ahí entra exclude:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - os: windows-latest
        python-version: '3.9'
      - os: macos-latest
        python-version: '3.9'

Esta configuración excluye las pruebas de Python 3.9 en Windows y macOS. De 3 x 4 = 12 tareas pasas a 10.

Casos típicos de exclude:

  1. Problemas de compatibilidad conocidos — una versión no funciona en un sistema concreto
  2. Límites de recursos — pocos runners self-hosted, hay que reducir combinaciones
  3. Escenarios marginales — combinaciones que casi nadie usa, no merecen tiempo de CI

4.2 include: añadir configuraciones especiales

include hace lo contrario: añade combinaciones extra o variables adicionales para combinaciones concretas.

Por ejemplo, quieres informes de cobertura solo en las pruebas de Python 3.12:

strategy:
  matrix:
    python-version: ['3.10', '3.11', '3.12']
    include:
      - python-version: '3.12'
        coverage: true

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}
  - run: pip install -r requirements.txt
  - name: Run tests
    run: |
      if [ "${{ matrix.coverage }}" = "true" ]; then
        pytest --cov=src --cov-report=xml
      else
        pytest
      fi
    shell: bash

Aquí include hace dos cosas:

  1. Añade una combinación — prueba de Python 3.12
  2. Añade una variable extracoverage: true

Casos típicos de include:

  1. Pruebas de versiones experimentales — p. ej. vista previa de Python 3.13 solo en un sistema
  2. Configuración especial — variables de entorno o parámetros extra en ciertas combinaciones
  3. Cobertura de casos marginales — combinaciones poco frecuentes, añadidas aparte en lugar de por permutación total

4.3 exclude e include pueden usarse juntos

En la práctica sueles necesitar ambos. Por ejemplo: excluir todas las combinaciones con Python 3.9, pero añadir una prueba mínima Ubuntu + Python 3.9:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - python-version: '3.9'
    include:
      - os: ubuntu-latest
        python-version: '3.9'
        minimal: true

El orden es: generar todas las combinaciones -> aplicar exclude -> aplicar include. Resultado: 4 versiones en Ubuntu, 3 en Windows (sin 3.9).

5. Estrategia fail-fast: fallo rápido frente a depuración completa

Por defecto, si una tarea de Matrix falla, las demás en ejecución se cancelan. Eso es fail-fast, activado por defecto.

strategy:
  fail-fast: true  # Valor por defecto, se puede omitir
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

5.1 Cuándo usar fail-fast: true (por defecto)

Pruebas en PR — un desarrollador abre un PR y quieres saber rápido si los tests pasan. Si una tarea falla, las demás probablemente también (mismo código), no tiene sentido seguir gastando tiempo.

Escenarios sensibles al coste — la cuota gratuita de GitHub Actions es limitada, y los runners self-hosted también. Fallar rápido ahorra dinero.

Mi hábito: fail-fast: true en PR, fail-fast: false en pruebas completas de main.

5.2 Cuándo usar fail-fast: false

Fase de depuración — las tareas de Matrix fallan a menudo y quieres ver qué combinaciones fallan y por qué. Con fail-fast: true solo ves la primera; el resto se cancela.

Pruebas de compatibilidad — pruebas multiplataforma y multiversión donde necesitas el resultado de cada combinación. Un fallo en una versión no debe ocultar el resto.

Informes completos — al terminar el CI necesitas un informe con el estado de todas las combinaciones.

strategy:
  fail-fast: false  # Dejar que todas las tareas terminen
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

5.3 Un caso real

El año pasado ayudé a depurar el CI de un proyecto: los tests fallaban siempre en Ubuntu + Node 18, pero el resto pasaba. Con fail-fast por defecto solo veían ese fallo y se cancelaban las demás tareas. Querían saber si Windows + Node 18 también fallaba, cambiaron a fail-fast: false, y Windows estaba bien — solo Ubuntu fallaba. Al final era un problema de mayúsculas/minúsculas en rutas de archivo.

Mi consejo: en depuración usa fail-fast: false para ver todos los problemas; en operación estable, fail-fast: true para ahorrar tiempo y dinero.

6. max-parallel: control de concurrencia y optimización de costes

Las tareas de Matrix se ejecutan en paralelo por defecto; GitHub lanza tantas simultáneas como pueda. En repos públicos el límite de runners hospedados es 20; en privados, la cuenta gratuita suele tener límite 2.

A veces necesitas limitar la concurrencia manualmente: ahí entra max-parallel.

6.1 Cuándo limitar la concurrencia

Recursos limitados en runners self-hosted — tu servidor tiene 4 núcleos y 8 GB; 8 tareas a la vez lo saturan.

Rate limiting de servicios externos — los tests llaman APIs de terceros con límite de QPS; demasiada concurrencia te bloquea.

Límite del pool de conexiones a base de datos — el pool tiene 10 conexiones; demasiadas tareas las agotan.

strategy:
  max-parallel: 4  # Como máximo 4 tareas a la vez
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

Esta configuración genera 6 tareas, pero solo 4 corren a la vez. Termina una, arranca la siguiente.

6.2 Un ejemplo de cálculo de coste

Supón un proyecto donde cada CI prueba 3 sistemas x 4 versiones de Node = 12 tareas. Cada tarea tarda de media 10 minutos.

Sin límite de concurrencia (runners suficientes):

  • 12 tareas en paralelo
  • Tiempo total ~10 minutos
  • Tiempo de cómputo total = 12 x 10 = 120 minutos

Con max-parallel: 4:

  • 12 tareas en 3 lotes
  • Tiempo total ~30 minutos
  • Tiempo de cómputo total = 12 x 10 = 120 minutos (igual)

¿Ves? max-parallel no reduce el tiempo de cómputo total, solo alarga la duración. ¿Por qué usarlo entonces?

Por el coste de pico de concurrencia y las limitaciones de recursos.

GitHub Actions factura por minutos, pero con runners self-hosted o proveedores que cobran por pico, controlar la concurrencia importa. Doce tareas a la vez pueden exigir 12 conexiones a la base de datos; en lotes de 4, bastan 4.

Mi experiencia práctica:

  • Repos públicos con runners de GitHub: no te preocupes por max-parallel, deja que programe solo
  • Repos privados con cuota gratuita: max-parallel: 2, despacio, sin pasar la cuota
  • Runners self-hosted: limita según el servidor; con 4 núcleos, 2-4 concurrentes

7. Matrix dinámico: técnicas avanzadas con fromJSON

Hasta aquí Matrix era estático: versiones fijas en el YAML. En algunos escenarios necesitas generar combinaciones según los cambios del código.

Por ejemplo, un monorepo con varios servicios, cada uno con su configuración de tests. Quieres probar solo los servicios tocados en el commit, no todos.

7.1 Workflow en dos pasos para Matrix dinámico

GitHub Actions no tiene sintaxis directa de matrix dinámico, pero un job puede generar la configuración y pasarla a otro. La clave es fromJSON().

jobs:
  # Paso 1: detectar servicios modificados y generar la matrix
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Necesitas el commit anterior

      - name: Detect changed services
        id: set-matrix
        run: |
          # Archivos modificados en este commit
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          # Qué servicios cambiaron
          SERVICES="[]"
          if echo "$CHANGED_FILES" | grep -q "services/auth/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["auth"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/api/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["api"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/web/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["web"]')
          fi

          # Si no hay cambios, probar todos por defecto
          if [ "$SERVICES" = "[]" ]; then
            SERVICES='["auth", "api", "web"]'
          fi

          echo "matrix={\"service\":$(echo $SERVICES)}" >> $GITHUB_OUTPUT

  # Paso 2: usar la matrix generada dinámicamente
  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4
      - name: Test ${{ matrix.service }}
        run: |
          cd services/${{ matrix.service }}
          npm install
          npm test

Cómo funciona:

  1. El job detect ve qué directorios cambiaron en el commit
  2. Genera un JSON de matrix según esos cambios
  3. El job test usa fromJSON() para parsearlo y crear las tareas

7.2 Casos típicos de matrix dinámico

Monorepo — probar solo servicios modificados, ahorrar tiempo de CI

Despliegue bajo demanda — detectar cambios en Dockerfile y construir solo imágenes actualizadas

Optimización de pruebas en matrix — decidir combinaciones según tipo de archivo (p. ej. matrix completa solo si cambia package.json)

Trampas que he pisado:

  1. fromJSON() solo vale en el valor de strategy.matrix, no en otros sitios
  2. El JSON debe ser un matrix válido, p. ej. {"service": ["auth", "api"]}
  3. Si la matrix queda vacía, el workflow falla — pon valores por defecto

8. Biblioteca de plantillas: 5 workflows listos para producción

Aquí van 5 plantillas que puedes copiar directamente, desde proyectos personales hasta uso empresarial.

8.1 Plantilla 1: pruebas multiversión en Node.js (básico)

Escenario: librería o app Node.js compatible con varias versiones de Node

name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22, 23]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build --if-present
      - run: npm test

      - name: Upload coverage
        if: matrix.node-version == 22
        uses: codecov/codecov-action@v4

Puntos clave:

  • Usa npm ci en lugar de npm install para versiones bloqueadas
  • Sube cobertura solo en Node 22, evita duplicados
  • cache: 'npm' acelera la instalación

8.2 Plantilla 2: Python multiplataforma y multiversión (intermedio)

Escenario: proyecto Python con pruebas cruzadas de SO y versión

name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          - os: windows-latest
            python-version: '3.10'  # Problema de compatibilidad conocido

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

      - name: Lint check
        run: |
          pip install ruff
          ruff check .

Puntos clave:

  • exclude para combinaciones problemáticas conocidas
  • cache: 'pip' acelera pip
  • Integración con ruff para lint

8.3 Plantilla 3: control preciso con exclude/include (avanzado)

Escenario: control fino de combinaciones, exclusiones y pruebas especiales

name: Advanced Matrix

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          # Excluir Windows + Python 3.10 (problema conocido)
          - os: windows-latest
            python-version: '3.10'
        include:
          # Prueba experimental: Ubuntu + vista previa Python 3.13
          - os: ubuntu-latest
            python-version: '3.13-dev'
            experimental: true
          # Cobertura en Python 3.12
          - python-version: '3.12'
            coverage: true

    continue-on-error: ${{ matrix.experimental == true }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - run: pip install -r requirements.txt

      - name: Run tests
        run: |
          if [ "${{ matrix.coverage }}" = "true" ]; then
            pytest --cov=src --cov-report=xml
          else
            pytest
          fi
        shell: bash

Puntos clave:

  • continue-on-error para que pruebas experimentales no tumben el estado global
  • include puede añadir combinaciones y variables a la vez
  • shell: bash unifica comandos en Windows y Linux

8.4 Plantilla 4: matrix dinámico + caching (avanzado)

Escenario: monorepo con combinaciones según archivos modificados

name: Dynamic Matrix CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect changed packages
        id: set-matrix
        run: |
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          PACKAGES="[]"
          for dir in packages/*/; do
            pkg=$(basename $dir)
            if echo "$CHANGED_FILES" | grep -q "^packages/$pkg/"; then
              PACKAGES=$(echo $PACKAGES | jq ". + [\"$pkg\"]")
            fi
          done

          # Sin cambios, probar todos los paquetes
          if [ "$PACKAGES" = "[]" ]; then
            PACKAGES='["core", "utils", "cli"]'
          fi

          echo "matrix={\"package\":$(echo $PACKAGES)}" >> $GITHUB_OUTPUT

  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build --if-present

      - name: Test ${{ matrix.package }}
        run: |
          cd packages/${{ matrix.package }}
          npm test

Puntos clave:

  • fetch-depth: 2 para comparar con el commit anterior
  • jq para manipular arrays JSON
  • Valores por defecto si no hay cambios, evita matrix vacía

8.5 Plantilla 5: runner self-hosted + max-parallel (empresarial)

Escenario: runners self-hosted con control estricto de concurrencia y recursos

name: Enterprise CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: [self-hosted, linux, x64]
    strategy:
      fail-fast: true
      max-parallel: 4
      matrix:
        java-version: [11, 17, 21]
        database: [postgresql, mysql]

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup Java ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'
          cache: 'maven'

      - name: Run tests with ${{ matrix.database }}
        env:
          DB_TYPE: ${{ matrix.database }}
          DB_HOST: localhost
          DB_PORT: ${{ matrix.database == 'postgresql' && 5432 || 3306 }}
        run: mvn test -Dspring.profiles.active=${{ matrix.database }}

      - name: Archive test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.java-version }}-${{ matrix.database }}
          path: target/surefire-reports

Puntos clave:

  • runs-on: [self-hosted, linux, x64] etiquetas del runner propio
  • max-parallel: 4 protege el servidor
  • services levanta contenedores de BD de prueba
  • if: always() sube resultados aunque fallen los tests

9. Trampas habituales y buenas prácticas

Llevo tiempo con Matrix y he caído en bastantes. Estas son las más comunes.

9.1 Trampa 1: explosión de combinaciones

La configuración más extrema que vi: 4 SO x 5 runtimes x 3 bases de datos x 2 esquemas de caché = 120 tareas. Cada CI tardaba 45 minutos y la factura explotó.

Soluciones:

  • Matrix completo solo en main; en PR, combinaciones clave
  • exclude para escenarios marginales
  • ¿De verdad necesitas 4 sistemas operativos en cada dimensión?
# PR solo prueba lo esencial
on:
  pull_request:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest]  # PR solo Ubuntu
        node: [20]            # PR solo Node 20

9.2 Trampa 2: fail-fast dificulta la depuración

fail-fast: true por defecto molesta al depurar: un fallo cancela el resto y no ves el informe completo.

Solución: pon fail-fast: false mientras depuras y vuelve a true después.

O controla con expresiones:

strategy:
  fail-fast: ${{ github.event_name == 'pull_request' }}

9.3 Trampa 3: falta de caching

Matrix repite el mismo job muchas veces; reinstalar dependencias cada vez cuesta caro. Una vez probé 12 combinaciones, 2 minutos de instalación cada una: 24 minutos solo en instalar.

Solución: caché de GitHub Actions o actions dedicadas.

- uses: actions/setup-node@v4
  with:
    node-version: ${{ matrix.node-version }}
    cache: 'npm'  # Clave: caché npm

9.4 Resumen de buenas prácticas

Práctica 1: Matrix pequeño en PR + Matrix completo en main

jobs:
  test:
    strategy:
      matrix:
        # PR solo combinaciones clave
        ${{ github.event_name == 'pull_request' && fromJSON('{"os":["ubuntu-latest"],"node":[20]}') || fromJSON('{"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}') }}

Práctica 2: exclude para combinaciones problemáticas conocidas

Ante un fallo de compatibilidad en una combinación concreta, usa exclude, anota un TODO y arréglalo después.

Práctica 3: caching para reducir tiempo de instalación

Instalar dependencias en cada job es de lo más lento; un buen caché pasa de minutos a segundos.

Práctica 4: nombres significativos para cada combinación

El nombre por defecto es tipo test (ubuntu-latest, 20). Personaliza con name:

jobs:
  test:
    name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20, 22]

Así identificas cada tarea más fácil en la interfaz de GitHub Actions.

10. Conclusión

Matrix en GitHub Actions es una herramienta potente para pruebas multiplataforma y multiversión. En el fondo son pocas cosas: definir dimensiones, controlar combinaciones, gestionar concurrencia y generar dinámicamente.

He visto demasiados proyectos manteniendo CI repetitivo a mano: cambiar un comando implica tocar docenas de sitios. Matrix comprime cientos de líneas en decenas y baja el coste de mantenimiento.

Repaso de lo esencial:

  1. Sintaxis básica: matrix.os y matrix.node hacen el producto cartesiano
  2. Control preciso: exclude quita combinaciones inválidas, include añade configuraciones especiales
  3. Estrategia: elige fail-fast según el escenario — false al depurar, true en producción
  4. Concurrencia: max-parallel protege runners self-hosted y controla costes
  5. Generación dinámica: fromJSON() permite pruebas bajo demanda y ahorra recursos de CI

Las 5 plantillas de este artículo puedes copiarlas tal cual, desde Node.js multiversión hasta configuración empresarial con runners propios.

Si empiezas con Matrix, te sugiero la plantilla 1, que funcione, luego añade exclude e include, y por último prueba la generación dinámica. Paso a paso; no te lances de golpe a lo más complejo.

Si tienes dudas, déjalas en los comentarios o consulta la documentación oficial de GitHub Actions. Si tienes trucos con Matrix, compártelos.

Configurar pruebas multiplataforma y multiversión con Matrix en GitHub Actions

Configurar la estrategia Matrix desde cero para pruebas automatizadas multiplataforma y multiversión

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Definir dimensiones de Matrix

    Añade strategy.matrix al job del workflow:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    ```

    Esto genera 2 x 3 = 6 tareas en paralelo.
  2. 2

    Step 2: Usar variables de Matrix

    Referencia las variables en runs-on y steps:

    ```yaml
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/setup-node@v4
    with:
    node-version: ${{ matrix.node }}
    ```

    Cada tarea obtiene automáticamente los valores de os y node correspondientes.
  3. 3

    Step 3: Excluir combinaciones concretas (opcional)

    Usa exclude para quitar combinaciones problemáticas conocidas:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
    - os: windows-latest
    node: 18
    ```

    Excluye Windows + Node 18; quedan 5 tareas.
  4. 4

    Step 4: Configurar estrategia de fallo

    Elige fail-fast según el escenario:

    - PR: fail-fast: true (fallo rápido, ahorro)
    - Depuración: fail-fast: false (ver todos los fallos)
    - Rama main: fail-fast: false (informe completo)

    ```yaml
    strategy:
    fail-fast: false
    matrix:
    # ...
    ```
  5. 5

    Step 5: Limitar concurrencia (opcional)

    Con runners self-hosted o recursos limitados, limita la concurrencia:

    ```yaml
    strategy:
    max-parallel: 4
    matrix:
    # ...
    ```

    Como máximo 4 tareas simultáneas, evita saturar el runner.

FAQ

¿Hay un límite en el número de combinaciones de Matrix?
GitHub impone límites blandos al número de tareas Matrix. En repos públicos, hasta 256 tareas; en privados, según el plan. En la práctica conviene mantenerse por debajo de 20 combinaciones para no alargar el CI ni disparar costes. 4 SO x 5 versiones x 3 bases de datos = 60 tareas ya es mucho.
¿Cuál es el valor por defecto de fail-fast?
fail-fast es true por defecto. Si una tarea falla, se cancelan las demás en ejecución. En depuración conviene false para ver todos los motivos; en producción, el valor por defecto ahorra costes al fallar rápido.
¿En qué orden se ejecutan exclude e include?
Orden: generar todas las combinaciones -> aplicar exclude -> aplicar include. Puedes excluir todas las combinaciones con Python 3.9 y luego usar include para añadir solo una prueba mínima Ubuntu + Python 3.9.
¿Dónde se puede usar fromJSON() en un matrix dinámico?
fromJSON() solo en el valor de strategy.matrix, no en otros campos YAML. El JSON debe ser un matrix válido, p. ej. {"os": ["ubuntu", "windows"]}. Si la matrix queda vacía, el workflow falla; añade valores por defecto.
¿max-parallel reduce el tiempo total de cómputo?
No. max-parallel solo limita cuántas tareas corren a la vez; no reduce el tiempo total de cómputo. 12 tareas x 10 minutos = 120 minutos, sea cual sea la concurrencia. Pero limitar la concurrencia puede: 1) bajar el pico de recursos; 2) evitar agotar el pool de conexiones; 3) controlar la carga del runner self-hosted.
¿Las tareas de Matrix pueden compartir caché?
Sí. La caché de GitHub Actions es a nivel de repositorio; todos los jobs pueden acceder. Activa cache en setup-node o setup-python para cachear dependencias. Conviene activarla en todas las tareas: la primera crea la caché, las siguientes la reutilizan.
¿Cómo poner nombres personalizados a las tareas de Matrix?
Usa el atributo name del job con variables de matrix:

• name: Test (${{ matrix.os }}, Node ${{ matrix.node }})

En la interfaz verás nombres claros como "Test (ubuntu-latest, Node 20)" y será más fácil identificar cada tarea.

16 min de lectura · Publicado el: 28 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog