CI para projeto Node.js com GitHub Actions
1. Visão geral
Toda vez que alguém faz push de código ou abre um pull request, você precisa saber imediatamente se o código ainda compila e se os testes passam. Isso deve ser automático, independente do computador de cada pessoa. Este artigo te guia na construção de um pipeline CI para um projeto Node.js usando GitHub Actions. Após ler, você terá um workflow que instala dependências com cache, executa lint, roda testes em múltiplas versões do Node, gera relatórios de coverage e automaticamente cancela execuções desatualizadas.
2. Preparando um projeto de exemplo
Vamos usar um módulo pequeno que calcula o total do pedido. Primeiro o package.json:
{
"name": "order-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"lint": "eslint src test",
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
}
}
O projeto usa o test runner nativo do Node (node:test), então não precisa instalar Jest ou Mocha. "type": "module" permite usar a sintaxe import/export. O script test:coverage usa dois reporters simultaneamente. Um reporter imprime o resultado no console. O outro reporter escreve o arquivo lcov.info para reter depois.
A lógica de negócio fica em src/order.js:
export function calculateTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discountPercent deve estar entre 0–100');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (100 - discountPercent)) / 100;
}
Esta função soma price * quantity de cada item e depois subtrai o desconto. Se o percentual de desconto estiver fora do intervalo 0–100, a função lança RangeError.
O teste correspondente está em test/order.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';
test('calcula o total do pedido com desconto', () => {
const items = [
{ price: 100000, quantity: 2 },
{ price: 50000, quantity: 1 },
];
assert.equal(calculateTotal(items, 10), 225000);
});
test('lança erro quando desconto é inválido', () => {
assert.throws(() => calculateTotal([], 120), RangeError);
});
O comando node --test encontra automaticamente arquivos com sufixo .test.js. Tente rodar localmente:
npm install --save-dev eslint @eslint/js
npm test
✔ calcula o total do pedido com desconto (0.9ms)
✔ lança erro quando desconto é inválido (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ESLint 9 usa flat config. Você cria o arquivo eslint.config.js no diretório raiz:
import js from '@eslint/js';
export default [js.configs.recommended];
Por fim, faça commit do arquivo package-lock.json. Como você verá depois, CI precisa deste arquivo.
3. Seu primeiro workflow de CI
GitHub Actions lê arquivos YAML no diretório .github/workflows/. Você cria o arquivo .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Cada parte do arquivo funciona da seguinte forma:
on: o workflow é executado quando há push paramaine quando há pull request para qualquer branch.runs-on: ubuntu-latest: o job é executado em uma máquina virtual Linux nova, fornecida pelo GitHub.actions/checkout@v4: clona o código-fonte para a máquina virtual.actions/setup-node@v4: instala Node 22. A opçãocache: npmarmazena o diretório de cache do npm, usando o hash depackage-lock.jsoncomo chave. Nas próximas execuções, os pacotes serão recuperados do cache em vez de baixados novamente do registry.npm ci: instala dependências exatamente como no lockfile.npm test: executa testes. Se o comando retornar um código de saída diferente de 0, o job falhará e o pull request será marcado como vermelho.
Depois de fazer push deste arquivo, acesse a aba Actions do repositório para ver os logs. Na segunda execução, o passo setup-node imprimirá uma linha semelhante a:
Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...
Nota: Não use
npm installem CI.npm installpode atualizar o lockfile e instalar uma versão diferente da sua máquina.npm ciexcluinode_modules, instala exatamente de acordo com o lockfile e reporta erro imediatamente sepackage.jsonepackage-lock.jsonnão correspondem. Graças a esse comportamento, você detecta cedo quando alguém esquece de fazer commit do lockfile.
4. Executando testes em múltiplas versões do Node
Sua biblioteca ou serviço pode precisar rodar em múltiplas versões do Node. A estratégia matrix ajuda a replicar o job para cada versão:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
O GitHub criará dois jobs em paralelo, nomeados como test (22) e test (24). A expressão ${{ matrix.node-version }} é substituída pelo valor correspondente em cada job.
Por padrão fail-fast é true: se apenas um job falhar, os outros são cancelados imediatamente. Definir fail-fast: false te mostra o quadro completo. Por exemplo, você descobre que o erro só ocorre no Node 24, enquanto Node 22 continua passando.
Você pode expandir a matrix para múltiplos sistemas operacionais:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
runs-on: ${{ matrix.os }}
Esta configuração cria 2 × 2 = 4 jobs.
Nota: Cada dimensão que você adiciona à matrix multiplica o número de jobs. Para um serviço que só faz deploy no Linux com Node 24, uma matrix grande apenas consome minutos de CI sem trazer benefício. Use matrix quando você realmente suportar múltiplos ambientes, por exemplo ao publicar um pacote npm.
Se seu projeto usa apenas uma versão do Node, deixe CI e sua máquina de desenvolvimento lerem da mesma fonte. Você cria um arquivo .nvmrc contendo 24 e depois declara:
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
5. Separando o job de lint e dependências entre jobs
Lint é muito mais rápido que testes e não precisa ser executado novamente em cada versão do Node. Portanto, é melhor separar lint em um job próprio e fazer o job de testes esperar lint passar:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
needs: lint faz o job test começar apenas quando lint é bem-sucedido. Suponha que alguém deixe uma variável não utilizada:
const unusedTax = 0.1;
Nesse caso, o job lint falhará com a mensagem:
/home/runner/work/order-service/src/order.js
1:7 error 'unusedTax' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
O job de testes não será executado, economizando tempo de toda a matrix.
Você precisa considerar o trade-off aqui. Usar needs faz o tempo total ser mais longo quando tudo passa, pois os dois jobs rodam sequencialmente. Se você prioriza velocidade de feedback, deixe de lado needs para que lint e testes rodem em paralelo.
6. Relatório de cobertura e salvando artefatos
Todos os arquivos gerados na máquina virtual são perdidos quando o job termina. Para manter o relatório de cobertura, você usa actions/upload-artifact:
coverage:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: lcov.info
retention-days: 7
O script test:coverage imprime a tabela de cobertura diretamente no log do job:
ℹ start of coverage report
ℹ file | line % | branch % | funcs %
ℹ src/order.js | 100.00 | 100.00 | 100.00
ℹ all files | 100.00 | 100.00 | 100.00
ℹ end of coverage report
A condição if: always() garante que o artefato seja enviado mesmo se o teste falhar. Este é exatamente o momento em que você mais precisa do relatório. O arquivo lcov.info aparece no final da página de resumo da execução do workflow e será deletado após 7 dias.
Se você quer que CI falhe quando a cobertura cair, a partir do Node 22.8 pode adicionar flag de limite ao script:
node --test --experimental-test-coverage --test-coverage-lines=80
Este comando retorna código de saída 1 quando o percentual de linhas cobertas fica abaixo de 80%.
7. Otimizando o workflow
As três configurações abaixo devem estar na maioria dos workflows de CI. Você as coloca no nível superior do arquivo:
name: CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
O significado de cada configuração:
paths-ignore: commits que apenas modificam README ou documentação não acionarão CI.permissions: contents: read: restringe permissões doGITHUB_TOKENpara apenas leitura. Se uma dependência maliciosa executar scripts em CI, ela também não poderá fazer push de código ou alterar releases.concurrency: quando você faz push de três commits sucessivamente para o mesmo pull request, apenas a execução mais recente é mantida. As execuções mais antigas são canceladas, pois seus resultados não têm mais valor.
Além disso, você deve adicionar timeout-minutes para cada job:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
Por padrão, um job pode rodar até 6 horas. Um único teste preso, por exemplo devido ao esquecimento de fechar uma conexão de banco de dados, pode consumir toda sua cota de minutos.
Nota:
paths-ignoretem uma armadilha ao combinar com proteção de branch. Suponha que você force o checktesta passar antes de fazer merge. Um pull request que apenas modifica arquivo.mdnão executará o workflow, portanto esse check permanece no estado “Expected — Waiting”. Neste ponto você tem duas opções: removerpaths-ignoreou remover o requisito obrigatório para esse check.
8. Erros comuns
Falta de lockfile. Se package-lock.json não for commitado ou estiver listado em .gitignore, o passo setup-node reportará erro:
Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock
A solução é fazer commit do lockfile. Se o projeto usa pnpm ou yarn, altere o valor de cache para o gerenciador de pacotes correto.
Testes executados em modo watch. Algumas ferramentas, como Vitest ou Jest quando configuradas para watch, aguardarão mudanças de arquivo indefinidamente. GitHub Actions define automaticamente a variável de ambiente CI=true, e a maioria das ferramentas a usa para desativar watch. Mesmo assim, você deve chamar o comando para executar uma vez explicitamente, por exemplo vitest run.
Secrets não disponíveis em pull request de fork. Para eventos pull_request de um repositório fork, os secrets não serão passados ao workflow. Este é um mecanismo de segurança intencional. Testes que necessitam de chaves de API reais falharão neste caso. Mock os serviços externos em testes unitários. Não mude para pull_request_target como “band-aid”, pois este evento é executado com permissões completas em código não revisado.
Diferenças do sistema operacional. Testes que passam em macOS mas falham em Ubuntu geralmente se devem à diferenciação de maiúsculas em caminhos. Por exemplo, import './Order.js' enquanto o arquivo real é nomeado order.js. O sistema de arquivos Linux diferencia maiúsculas de minúsculas, então CI detecta este erro antes dele ir para produção.
Quando você não precisa de todo esse pipeline. Para um script interno de apenas algumas dezenas de linhas, um único job npm ci && npm test é suficiente. Você deve adicionar matrix, coverage e jobs separados apenas quando o projeto cresce ou quando há muitas pessoas contribuindo.
9. Conclusão
Neste artigo, você construiu um pipeline CI completo para um projeto Node.js com GitHub Actions. O pipeline usa npm ci junto com cache de setup-node para instalar dependências de forma rápida e estável. A estratégia matrix ajuda a testar em múltiplas versões do Node. O job de lint é separado e vinculado ao job de teste via needs. O relatório de cobertura é mantido via artefato. Por fim, o workflow é otimizado com concurrency, permissions e timeout-minutes.
O princípio central é manter CI rápido e confiável. Um pipeline que leva quinze minutos ou falha ocasionalmente de forma aleatória fará a equipe ignorar gradualmente seus resultados. Comece com o workflow minimalista da seção 3 e adicione partes conforme o projeto realmente precisar.