Alternar tema

GitHub Actions Matrix: guia prático para testes paralelos em várias plataformas e versões

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

No ano passado, um projeto open source me procurou porque o arquivo de configuração de CI já tinha passado de 800 linhas. Abri o arquivo e era uma sequência densa de definições de job repetidas: Node 16 rodando no Ubuntu, Node 16 rodando no Windows, Node 16 rodando no macOS… depois tudo de novo para Node 18, e mais uma vez para Node 20. Mudar um comando de teste? Tinha que alterar 12 pontos. Adicionar uma versão nova? Mais alguns minutos copiando e colando.

Foi aí que caiu a ficha: muita gente ainda mantém testes em várias versões e plataformas na mão.

O recurso Matrix do GitHub Actions, resumindo, serve para expandir automaticamente essas configurações repetidas. Você define alguns sistemas operacionais e algumas versões de runtime, e ele roda todas as combinações. Parece simples, mas na prática há várias pegadinhas: explosão de combinações que vira explosão de custo, uma falha cancelando o restante da execução, dúvidas sobre como excluir combinações específicas… eu já passei por todas.

Este artigo começa pela sintaxe mais básica do Matrix e avança, passo a passo, por controle preciso com exclude/include, escolha da estratégia fail-fast, limite de concorrência com max-parallel e geração dinâmica de Matrix. No final, deixo 5 templates de workflow prontos para copiar para projetos reais, cobrindo cenários de projetos pessoais a aplicações empresariais.

2. Conceito central do Matrix: expandir vários jobs com uma configuração

A lógica central do Matrix é simples: você define algumas dimensões, e o GitHub Actions calcula o produto cartesiano automaticamente.

Imagine que seu projeto precisa ser testado em Ubuntu, Windows e macOS, além de ser compatível com Node.js 18, 20 e 22. Na abordagem tradicional, você escreveria 9 jobs na mão, repetindo ambiente de execução, etapas de instalação e comandos de teste. Com Matrix, basta isto:

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

Com essas 10 linhas, o GitHub Actions expande automaticamente 3 x 3 = 9 jobs paralelos. Cada job recebe valores diferentes de matrix.os e matrix.node e executa sua própria combinação.

Naquele projeto com um arquivo de 800 linhas, a refatoração com Matrix reduziu a configuração para cerca de 120 linhas, uma queda de mais de 60% no código. A manutenção também ficou mais barata: para adicionar uma versão, basta colocar mais um número no array, sem copiar e colar uma pilha de definições de job.

O que Matrix ajuda você a fazer:

  • Gerar combinações de testes em várias plataformas e versões com uma só configuração
  • Expandir automaticamente as configurações e evitar código repetido escrito à mão
  • Excluir combinações problemáticas com exclude
  • Adicionar casos com configurações especiais usando include
  • Controlar a quantidade de jobs paralelos para equilibrar velocidade e custo

O que ele não resolve:

  • Se os testes foram mal escritos, Matrix não salva
  • Combinações demais podem estourar a conta; controlar o número de dimensões continua sendo sua responsabilidade
  • Instalação lenta de dependências precisa ser tratada com estratégia de cache

Sinceramente, o Matrix em si não é difícil de entender. O difícil é usá-lo para resolver problemas reais de engenharia. Vamos começar pela sintaxe básica e desmontar a ideia peça por peça.

3. Sintaxe básica: como funciona a combinação os x version

A regra de combinação do Matrix é o produto cartesiano da matemática. Cada dimensão que você define é combinada com todas as outras.

Uma dimensão, N valores -> N jobs

Duas dimensões, M x N valores -> M x N jobs

Três dimensões, A x B x C valores -> A x B x C jobs

Vamos a um exemplo concreto. Suponha que você tenha um projeto Python que precisa testar Python 3.9, 3.10, 3.11 e 3.12 em Linux e Windows, além de validar PostgreSQL e 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: |
          # Inicia o serviço de banco de dados correspondente
          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

Essa configuração gera 2 x 4 x 2 = 16 jobs. Cada job roda em um ambiente independente, sem interferir nos outros.

Como acessar variáveis do Matrix:

  • ${{ matrix.os }} — obtém o sistema operacional do job atual
  • ${{ matrix.python-version }} — obtém a versão do Python do job atual
  • ${{ matrix.database }} — obtém o tipo de banco de dados do job atual

Essas variáveis podem ser usadas em runs-on, steps, env e outros pontos para ajustar dinamicamente o comportamento de cada job.

Uma pegadinha comum: muita gente acha que o Matrix resolve automaticamente a instalação de dependências. Na verdade, cada job roda em um ambiente separado, então a instalação é repetida. Isso cria um problema: se instalar dependências leva 2 minutos, 16 jobs representam 32 minutos de espera se fossem executados em série.

Há duas formas de aliviar isso:

  1. Usar cache — armazenar o diretório de dependências do pip ou npm para evitar downloads repetidos
  2. Reduzir combinações — usar exclude para remover combinações desnecessárias

Eu já expliquei estratégia de cache em detalhes no artigo [Estratégia de cache no GitHub Actions: acelere pipelines CI/CD em 5 vezes], então não vou abrir esse tema aqui. Vamos focar em como usar exclude/include para controlar as combinações com precisão.

4. exclude/include: controle preciso das combinações de teste

Por padrão, Matrix faz a combinação completa de todas as dimensões. Em projetos reais, porém, você frequentemente encontra situações em que algumas combinações não precisam ser testadas ou exigem tratamento especial.

4.1 exclude: remover combinações inválidas

Quando eu mantinha um projeto Python, bati neste problema: a combinação Windows + Python 3.9 falhava sempre, porque uma dependência tinha problema de compatibilidade nessa versão no Windows. Mas o projeto era voltado principalmente para deploy em servidores Linux; Windows era suporte secundário, e não valia gastar tempo corrigindo aquele bug específico.

É aí que exclude entra:

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'

Essa configuração remove os testes de Python 3.9 no Windows e no macOS. O Matrix original teria 3 x 4 = 12 jobs; depois de excluir 2, ficam 10.

Cenários típicos para usar exclude:

  1. Problemas conhecidos de compatibilidade — certa versão não roda em um sistema específico
  2. Limite de recursos — poucos runners self-hosted disponíveis e necessidade de reduzir combinações
  3. Casos periféricos — combinações que quase nenhum usuário usa e que não merecem tempo de CI

4.2 include: adicionar configurações especiais

include faz o oposto: ajuda a adicionar combinações extras ou a complementar combinações específicas com variáveis adicionais.

Por exemplo, você quer ativar relatório de cobertura apenas nos testes com Python 3.12, sem fazer isso nas outras versões:

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

Aqui, include faz duas coisas:

  1. Adiciona uma nova combinação — o teste com Python 3.12
  2. Complementa essa combinação com uma variável extra — coverage: true

Cenários típicos para usar include:

  1. Testes com versões experimentais — por exemplo, Python 3.13 preview, testado só em um sistema
  2. Configurações especiais — algumas combinações precisam de variáveis de ambiente ou parâmetros extras
  3. Cobertura de casos periféricos — combinações raras adicionadas separadamente em vez de geradas pelo produto cartesiano completo

4.3 exclude e include podem ser usados juntos

Em projetos reais, é comum usar exclude e include ao mesmo tempo. Por exemplo: remover todas as combinações com Python 3.9, mas adicionar separadamente um teste mínimo com 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

A ordem de execução dessa configuração é: gerar todas as combinações -> aplicar exclude -> aplicar include. Resultado final: Ubuntu roda 4 versões; Windows roda 3 versões, sem 3.9.

5. Estratégia fail-fast: falhar rápido vs depurar tudo

Jobs de Matrix têm um comportamento padrão: quando um job falha, os outros jobs ainda em execução são cancelados. Esse comportamento se chama fail-fast e vem ativado por padrão.

strategy:
  fail-fast: true  # valor padrão, pode ser omitido
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

5.1 Quando usar fail-fast: true (padrão)

Testes de PR — alguém abriu um PR e você quer saber rápido se há problema. Se um job falha, há boa chance de os outros também falharem pelo mesmo erro de código, então não vale gastar tempo continuando.

Cenários sensíveis a custo — a cota gratuita do GitHub Actions é limitada, e os recursos de runners self-hosted também. Falhar rápido pode economizar um bom dinheiro.

Minha preferência pessoal é: fail-fast: true em testes de PR e fail-fast: false nos testes completos da branch main.

5.2 Quando usar fail-fast: false

Fase de depuração — seus jobs de Matrix falham com frequência, e você quer saber exatamente quais combinações falharam e por quê. Com fail-fast: true, você só enxerga o primeiro job que falhou; os demais são cancelados.

Testes de compatibilidade — você está validando várias versões e plataformas e quer o resultado de cada combinação. Mesmo que uma versão tenha problema, isso não deve impedir a coleta de informação sobre as outras.

Relatório completo — você precisa gerar, ao final do CI, um relatório com o status de aprovação/falha de todas as combinações.

strategy:
  fail-fast: false  # deixa todos os jobs terminarem
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

5.3 Um caso real

No ano passado, ajudei um projeto a investigar um problema de CI. Os testes sempre falhavam em Ubuntu + Node 18, mas as outras combinações passavam. Como fail-fast estava ativado por padrão, cada execução mostrava apenas a falha em Ubuntu + Node 18 e cancelava os outros jobs. Depois, eles quiseram saber se Windows + Node 18 também tinha problema, mudaram para fail-fast: false e descobriram que no Windows estava tudo certo. O problema era só no Ubuntu, causado por compatibilidade de maiúsculas/minúsculas em caminhos de arquivo.

Minha recomendação: na fase de desenvolvimento e depuração, use fail-fast: false para enxergar todos os problemas; quando estiver estável, use fail-fast: true para economizar dinheiro e tempo.

6. max-parallel: controle de concorrência e otimização de custo

Jobs de Matrix rodam em paralelo por padrão, e o GitHub inicia o máximo de jobs simultâneos possível. Para repositórios públicos, o limite de concorrência dos runners hospedados pelo GitHub é 20; para repositórios privados, contas gratuitas têm limite de 2.

Mas às vezes você precisa controlar manualmente a concorrência. É para isso que serve max-parallel.

6.1 Quando limitar a concorrência

Runner self-hosted com poucos recursos — seu servidor de runner tem 4 núcleos e 8 GB de RAM; rodar 8 jobs ao mesmo tempo pode derrubar a máquina.

Rate limit de serviço externo — seus testes chamam uma API de terceiros com limite de QPS, e concorrência alta pode gerar bloqueio.

Limite do pool de conexões do banco — seus testes conectam a um banco com pool de 10 conexões; jobs demais podem esgotar o pool.

strategy:
  max-parallel: 4  # no máximo 4 jobs ao mesmo tempo
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

Essa configuração gera 6 jobs, mas no máximo 4 rodam ao mesmo tempo. Quando um termina, o próximo começa.

6.2 Um exemplo de cálculo de custo

Suponha que seu projeto precise testar 3 sistemas x 4 versões do Node = 12 jobs a cada execução de CI. Cada job leva, em média, 10 minutos.

Sem limite de concorrência (assumindo runners suficientes):

  • 12 jobs rodam ao mesmo tempo
  • Tempo total aproximado: 10 minutos
  • Tempo total de computação = 12 x 10 = 120 minutos

Com max-parallel: 4:

  • 12 jobs rodam em 3 lotes
  • Tempo total aproximado: 30 minutos
  • Tempo total de computação = 12 x 10 = 120 minutos (não muda)

Percebeu? max-parallel não reduz o tempo total de computação; ele aumenta a duração total da execução. Então por que usar?

Por causa de custo de pico de concorrência e limites de recursos.

O GitHub Actions cobra por minutos, mas se você usa runner self-hosted ou um provedor de nuvem que cobra pelo pico, controlar a concorrência é importante. Por exemplo: com 12 jobs simultâneos, seu banco precisa de 12 conexões; em lotes, precisa de apenas 4.

Minha prática:

  • Repositórios públicos com runners hospedados pelo GitHub: não mexa em max-parallel; deixe o GitHub agendar
  • Repositórios privados com cota gratuita: use max-parallel: 2 e rode mais devagar, sem estourar a cota
  • Runner self-hosted: limite max-parallel conforme a máquina; em 4 núcleos, eu começaria com 2 a 4 jobs paralelos

7. Matrix dinâmico: técnica avançada com fromJSON

Até aqui, os exemplos de Matrix foram estáticos: você escreve no YAML quais versões serão testadas. Mas em alguns cenários, as combinações precisam ser geradas dinamicamente com base nas mudanças de código.

Imagine um monorepo com vários serviços, cada um com sua própria configuração de teste. Você quer testar apenas os serviços afetados pelo commit atual, não todos os serviços a cada execução.

7.1 Workflow em duas etapas para implementar matrix dinâmico

O GitHub Actions não tem uma sintaxe direta para “matrix dinâmico”, mas você pode gerar a configuração de matrix em um job e passá-la para outro. A chave é a função fromJSON().

jobs:
  # Primeira etapa: detecta serviços alterados e gera a configuração do matrix
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # precisa obter o commit anterior

      - name: Detect changed services
        id: set-matrix
        run: |
          # Obtém os arquivos alterados neste commit
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          # Verifica quais serviços foram alterados
          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

          # Se nenhum serviço mudou, testa todos por padrão
          if [ "$SERVICES" = "[]" ]; then
            SERVICES='["auth", "api", "web"]'
          fi

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

  # Segunda etapa: usa o matrix gerado dinamicamente
  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

Esse workflow funciona assim:

  1. O job detect verifica quais diretórios foram alterados neste commit
  2. Com base nos diretórios alterados, gera uma configuração de matrix em JSON
  3. O job test usa fromJSON() para analisar essa configuração e criar os jobs correspondentes

7.2 Casos típicos para matrix dinâmico

Monorepo — testar apenas os serviços alterados e economizar tempo de CI

Deploy sob demanda — detectar mudanças em Dockerfile e construir/publicar apenas imagens atualizadas

Otimização de testes de Matrix — escolher combinações com base nos tipos de arquivo alterados, como rodar a matriz completa de versões apenas quando package.json muda

Pegadinhas que eu já encontrei:

  1. fromJSON() só pode ser usado no valor de strategy.matrix, não em outros lugares
  2. O JSON gerado precisa ser um matrix válido, como {"service": ["auth", "api"]}
  3. Se o matrix gerado ficar vazio, o workflow falha diretamente; sempre inclua um valor padrão

8. Biblioteca prática: 5 exemplos de workflow prontos para produção

A seguir estão 5 templates de workflow que você pode copiar diretamente, cobrindo de projetos pessoais a aplicações empresariais.

8.1 Template 1: testes Node.js em várias versões (básico)

Cenário adequado: biblioteca ou aplicação Node.js que precisa ser compatível com várias versões do 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

Pontos importantes:

  • Use npm ci em vez de npm install para garantir versões travadas das dependências
  • Faça upload de cobertura apenas no Node 22 para evitar envios duplicados
  • cache: 'npm' ativa o cache do npm e acelera a instalação de dependências

8.2 Template 2: testes Python em várias plataformas e versões (intermediário)

Cenário adequado: projeto Python que precisa testar entre plataformas e versões

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 conhecido de compatibilidade

    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 .

Pontos importantes:

  • Use exclude para remover combinações com problemas conhecidos
  • cache: 'pip' acelera a instalação de dependências do pip
  • Integre uma ferramenta de lint como ruff

8.3 Template 3: controle preciso com exclude/include (avançado)

Cenário adequado: quando você precisa controlar combinações com precisão, excluir algumas e adicionar testes especiais

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:
          # Remove Windows + Python 3.10 (problema conhecido)
          - os: windows-latest
            python-version: '3.10'
        include:
          # Adiciona um teste experimental: Ubuntu + Python 3.13 preview
          - os: ubuntu-latest
            python-version: '3.13-dev'
            experimental: true
          # Adiciona relatório de cobertura ao 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

Pontos importantes:

  • continue-on-error faz testes experimentais falharem sem afetar o status geral
  • include pode adicionar novas combinações e complementar variáveis ao mesmo tempo
  • shell: bash garante comandos consistentes no Windows e no Linux

8.4 Template 4: matrix dinâmico + caching (avançado)

Cenário adequado: monorepo em que as combinações de teste são geradas dinamicamente conforme mudanças nos arquivos

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

          # Se não houver mudança, testa todos os pacotes
          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

Pontos importantes:

  • fetch-depth: 2 obtém o commit anterior para comparação
  • Use jq para manipular arrays JSON
  • Forneça um valor padrão quando não houver mudanças, evitando erro de matrix vazio

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

Cenário adequado: runner self-hosted com controle rígido de concorrência e 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

Pontos importantes:

  • runs-on: [self-hosted, linux, x64] especifica as labels do runner self-hosted
  • max-parallel: 4 limita a concorrência e protege o servidor de runner
  • Use services para iniciar containers de banco de dados durante os testes
  • if: always() garante upload dos resultados mesmo quando os testes falham

9. Armadilhas comuns e boas práticas

Depois de usar Matrix por bastante tempo, já caí em várias armadilhas. Estas são as mais comuns.

9.1 Armadilha 1: explosão de combinações

A configuração mais exagerada que já vi tinha 4 sistemas operacionais x 5 versões de runtime x 3 bancos de dados x 2 estratégias de cache = 120 jobs. Cada execução de CI levava 45 minutos, e a conta subiu direto.

Como resolver:

  • Rode o Matrix completo apenas na branch main; em PRs, rode só combinações principais
  • Use exclude para remover casos periféricos
  • Questione a necessidade de cada dimensão: você realmente precisa testar 4 sistemas operacionais?
# PR testa apenas combinações essenciais
on:
  pull_request:
    branches: [main]

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

9.2 Armadilha 2: fail-fast atrapalha a depuração

O padrão fail-fast: true incomoda bastante na fase de depuração: um job falha, todos os outros são cancelados, e você não vê o relatório completo.

Como resolver: durante a depuração, mude manualmente para fail-fast: false; depois de corrigir, volte ao comportamento desejado.

Ou controle com uma variável de ambiente:

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

9.3 Armadilha 3: falta de caching

Matrix executa o mesmo job várias vezes. Se cada execução reinstala dependências do zero, o custo de tempo fica alto. Já testei 12 combinações em que cada instalação levava 2 minutos; só instalar dependências consumia 24 minutos.

Como resolver: use cache do GitHub Actions ou uma action dedicada de cache.

- uses: actions/setup-node@v4
  with:
    node-version: ${{ matrix.node-version }}
    cache: 'npm'  # essencial: ativa cache do npm

9.4 Resumo de boas práticas

Prática 1: Matrix pequeno em PR + Matrix completo na main

jobs:
  test:
    strategy:
      matrix:
        # PR testa apenas combinações principais
        ${{ github.event_name == 'pull_request' && fromJSON('{"os":["ubuntu-latest"],"node":[20]}') || fromJSON('{"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}') }}

Prática 2: usar exclude para combinações com problemas conhecidos

Ao encontrar problema de compatibilidade em uma combinação específica, pule com exclude, registre um TODO e corrija depois.

Prática 3: combinar com caching para reduzir tempo de instalação

Instalar dependências é uma das partes mais demoradas de cada job. Um bom cache pode reduzir esse tempo de minutos para segundos.

Prática 4: adicionar names significativos às combinações

O nome padrão do job fica em um formato como test (ubuntu-latest, 20). Você pode personalizar com name:

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

Assim, cada job fica mais fácil de identificar na interface do GitHub Actions.

10. Conclusão

GitHub Actions Matrix é uma ferramenta forte para testes em várias plataformas e versões. O núcleo se resume a quatro movimentos: definir dimensões, controlar combinações, gerenciar concorrência e gerar configurações dinamicamente.

Já vi muitos projetos ainda mantendo configurações repetidas de CI na mão, com um comando precisando ser alterado em mais de dez lugares. Matrix reduz centenas de linhas para algumas dezenas e baixa o custo de manutenção imediatamente.

Recapitulando os pontos principais:

  1. Sintaxe básica: matrix.os e matrix.node expandem pelo produto cartesiano
  2. Controle preciso: exclude remove combinações inválidas; include adiciona configurações especiais
  3. Escolha de estratégia: ajuste fail-fast ao cenário; false para depurar, true para produção
  4. Limite de concorrência: max-parallel protege runners self-hosted e controla custo
  5. Geração dinâmica: fromJSON() permite testes sob demanda e economiza recursos de CI

Os 5 templates deste artigo podem ser copiados diretamente para o seu projeto, desde o teste mais simples em várias versões do Node.js até uma configuração empresarial com runner self-hosted.

Se você está começando com Matrix, recomendo partir do Template 1. Depois que ele estiver rodando, adicione exclude e include; só então avance para geração dinâmica. Vá por etapas, sem complicar tudo logo de início.

Se tiver dúvidas, deixe um comentário ou consulte a documentação oficial do GitHub Actions para mais detalhes. Se você tiver aprendizados práticos com Matrix, também vale compartilhar.

Configurar testes GitHub Actions Matrix em várias plataformas e versões

Configure uma estratégia Matrix do zero para automatizar testes entre plataformas e versões.

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Definir as dimensões do Matrix

    Adicione a configuração strategy.matrix ao job do workflow:

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

    Isso gera 2 x 3 = 6 jobs paralelos.
  2. 2

    Step 2: Usar variáveis do Matrix

    Referencie variáveis de matrix em runs-on e steps:

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

    Cada job recebe automaticamente os valores correspondentes de os e node.
  3. 3

    Step 3: Excluir combinações específicas (opcional)

    Use exclude para remover combinações com problemas conhecidos:

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

    A combinação Windows + Node 18 é removida, gerando 5 jobs no final.
  4. 4

    Step 4: Configurar a estratégia de falha

    Escolha a estratégia fail-fast de acordo com o cenário:

    - Testes de PR: fail-fast: true (falha rápido e economiza custo)
    - Fase de depuração: fail-fast: false (mostra todas as falhas)
    - Branch main: fail-fast: false (relatório completo)

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

    Step 5: Limitar a concorrência (opcional)

    Ao usar runner self-hosted ou recursos limitados, limite a concorrência:

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

    No máximo 4 jobs rodam ao mesmo tempo, evitando sobrecarga no runner.

FAQ

Existe limite para o número de combinações do Matrix?
O GitHub tem limites práticos para a quantidade de jobs gerados por Matrix. Repositórios públicos podem chegar a 256 jobs; em repositórios privados, isso depende do plano. Na prática, recomendo manter as combinações abaixo de 20 para evitar CI demorado e conta inflada. 4 sistemas operacionais x 5 versões x 3 bancos de dados = 60 jobs já é bastante.
Qual é o valor padrão de fail-fast?
O valor padrão de fail-fast é true. Depois que um job falha, outros jobs em execução são cancelados. Na fase de depuração, vale definir false para enxergar todas as causas de falha; em produção, o padrão ajuda a falhar rápido e economizar custo.
Qual é a ordem de execução de exclude e include?
A ordem é: gerar todas as combinações -> aplicar exclude -> aplicar include. Assim, você pode excluir todas as combinações com Python 3.9 e depois usar include para adicionar apenas um teste mínimo de Ubuntu + Python 3.9.
Onde o fromJSON() pode ser usado em um matrix dinâmico?
fromJSON() só pode ser usado no valor de strategy.matrix, não em outros campos YAML. O JSON gerado precisa seguir um formato de matrix válido, como {"os": ["ubuntu", "windows"]}. Se o matrix gerado estiver vazio, o workflow falha; por isso, inclua um valor padrão.
max-parallel reduz o tempo total de computação?
Não. max-parallel só controla quantos jobs rodam ao mesmo tempo; ele não reduz o tempo total de computação. 12 jobs x 10 minutos = 120 minutos de computação, independentemente da concorrência. Mas limitar a concorrência ajuda a: 1) reduzir o pico de uso de recursos; 2) evitar esgotamento do pool de conexões do banco; 3) controlar a carga em runners self-hosted.
Jobs do Matrix podem compartilhar cache?
Sim. O cache do GitHub Actions é em nível de repositório, então todos os jobs conseguem acessá-lo. Ative o parâmetro cache em setup-node ou setup-python para armazenar automaticamente o diretório de dependências. Recomendo habilitar cache em todos os jobs: o primeiro cria o cache e os seguintes reutilizam.
Como adicionar um nome personalizado a jobs do Matrix?
Use a propriedade name do job junto com variáveis de matrix:

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

Assim, a interface do GitHub Actions mostra nomes amigáveis como "Test (ubuntu-latest, Node 20)", facilitando identificar cada job.

1 min de leitura · Publicado em: 28 abr 2026 · Atualizado em: 14 jul 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog