Blog

Como Reduzimos o Tempo de Espera do Nosso CI de 30 Minutos para 5 Minutos

CI lento é uma daquelas coisas que irrita a equipe de engenharia. Os engenheiros esperam mais para receber feedback, o contexto muda com mais frequência e a tentação de agrupar mudanças cresce, o que só piora tudo. Passamos algumas semanas otimizando nossos workflows do GitHub Actions e reduzimos nossos checks de PR de 30 minutos para 5 minutos. Isso representa uma redução de 83% no tempo de espera, conquistada sem sacrificar nenhuma cobertura de teste ou porta de qualidade. Não houve uma única solução mágica. Olhamos para onde o tempo estava realmente indo e aplicamos três princípios: dependências inteligentes entre jobs, cache agressivo e particionamento de testes. Aqui está o que aprendemos.

Keith WilliamsJan 1, 2026

A base: migração para Blacksmith

Na verdade, já tínhamos tentado migrar para Blacksmith antes, mas encontramos problemas. Com uma branch main tão ativa quanto a nossa, não queríamos lidar com instabilidade no CI. Desta vez, a migração foi simples (#26247): trocar os runners buildjet-*vcpu-ubuntu-2204 por blacksmith-*vcpu-ubuntu-2404 em 28 arquivos de workflow, substituir as ações cache e setup-node e ajustar as alocações de vCPU. O único problema real que encontramos foi o cache corrompido com arquivos .next (mais sobre isso na seção de cache).

Depois que atualizamos, notamos imediatamente uma melhora de 2x na velocidade de pull do cache. Como o yarn install tem 1,2 GB, isso economiza cerca de 22 segundos em cada job que precisa rodar yarn install, que é a maioria deles. Também estamos usando o cache de containers do Blacksmith para serviços usados em vários jobs (Postgres, Redis, Mailhog). O job Initialize containers agora ficou mais de 50% mais rápido, caindo de 20 segundos para 9 segundos.

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses: ./.github/actions/cache-build

O que tivemos que corrigir

Os runners do Blacksmith expõem a quantidade de CPUs da máquina host em vez das vCPUs alocadas (normalmente 4). O Playwright, querendo ajudar, criava workers para corresponder a isso, causando contenção de recursos e falhas intermitentes nos testes. Só precisávamos limitar explicitamente os workers com --workers=4 para corresponder à nossa alocação real.

Também descobrimos que um paralelismo maior expôs condições de corrida na nossa lógica de limpeza dos testes. Testes de integração que funcionavam bem em sequência passavam a colidir quando executados simultaneamente, gerando erros de idempotencyKey. A solução exigiu repensar como os testes fazem a própria limpeza: consultar reservas por eventTypeId em vez de rastrear IDs individuais de reservas, o que deixava passar reservas criadas indiretamente pelo código sob teste (#26269, #26278, #26283).

Até nossos testes de captura de tela precisaram de ajustes (#26247). Runners diferentes têm pequenas diferenças na renderização de fontes, então adicionamos deviceScaleFactor: 1 para uma renderização consistente e aumentamos levemente nosso limite de diff de 0.05 para 0.07 para levar em conta variações de antisserrilhado sem mascarar regressões reais.

Mover mais rápido costuma expor acoplamentos ocultos no seu sistema. O acoplamento sempre esteve lá; você só não conseguia enxergá-lo em velocidades mais baixas. Corrigir esses problemas é o que permite melhorias sustentáveis de velocidade.

Princípio 1: Encurte o caminho crítico com design de dependências

Para nós, um grande problema era que o lint estava bloqueando coisas que não deveria bloquear. Quando seus testes E2E levam de 7 a 10 minutos, travá-los por causa de 2 a 3 minutos de lint e verificação de tipos só deixa todo o workflow mais longo. O lint termina bem antes do E2E, então por que não deixá-los rodar em paralelo?

Desacople suítes de teste não relacionadas

Nossos testes de integração e a principal suíte E2E não precisam da build da API v2, então removemos essa dependência (#26170). Por outro lado, os testes E2E da API v2 não precisam da build principal da web. Agora cada suíte de testes depende apenas do que realmente precisa, permitindo paralelismo máximo.

Consolide jobs de pré-checagem

Tínhamos jobs separados para detecção de mudanças em arquivos (changes) e verificação de label de E2E (check-label). Cada job tem um custo de inicialização: subir um runner, fazer checkout do código, restaurar contexto. Ao unir isso em um único job prepare, eliminamos overhead redundante (#26101). Fomos além e movemos nossas etapas de instalação de dependências para o job prepare também, economizando mais 20 segundos por workflow ao eliminar mais uma fronteira entre jobs (#26320).

Torne os relatórios não bloqueantes

Relatórios de testes E2E são valiosos, mas não deveriam ficar no caminho crítico. Movemos os jobs merge-reports, publish-report e cleanup-report para um workflow separado acionado por workflow_run (#26157). Agora os engenheiros podem rodar novamente os jobs com falha imediatamente, sem esperar a geração do relatório terminar.

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types:
      - completed

A regra é simples: seja flexível para permitir que os jobs E2E comecem a rodar o quanto antes. Não bloqueie jobs longos por pré-requisitos curtos, a menos que exista uma dependência real. O tempo total do seu CI é determinado pelo caminho crítico, e toda dependência desnecessária estende esse caminho.

Princípio 2: Cache é rei (mas só se acertar)

Cache eficiente exige chaves que invalidem quando devem, permaneçam estáveis quando devem e um armazenamento de cache que continue saudável ao longo do tempo.

Use lookup-only

Nossa melhoria de cache mais dramática veio de uma observação simples: estávamos baixando aproximadamente 1,2 GB de dados em cache mesmo quando não precisávamos instalar nada. O cache existia, o hit funcionava, mas ainda estávamos pagando o custo do download antes de descobrir que podíamos pular a etapa de instalação.

A correção foi usar verificações de cache com lookup-only (#26314). Temos etapas dedicadas que rodam antes de todos os jobs para garantir que as dependências estejam instaladas, então, quando os jobs individuais começam, sabemos que o cache existe. Antes de tentar restaurar o cache completo, primeiro verificamos se ele existe sem baixar nada. Se todos os caches tiverem hit, pulamos todo o fluxo de restauração e instalação. Sem downloads, sem instalações, só seguir para o trabalho de fato. Isso, sozinho, economizou bastante tempo em todo cenário de cache hit, que, com um bom design de chaves de cache, deveria ser a maioria das execuções.

- name: Check yarn cache (lookup-only)
  if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
  uses: actions/cache/restore@v4
  id: yarn-download-cache-check
  with:
    path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
    key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
    lookup-only: true

Aplicamos a mesma lógica à instalação do navegador do Playwright (#26060). Como temos uma etapa dedicada que garante que os navegadores do Playwright estejam em cache antes de os jobs de teste rodarem, podemos usar a mesma abordagem lookup-only. Se o cache do Playwright tiver hit, pulamos totalmente a instalação. Agora primeiro verificamos e só depois decidimos se vamos baixar algo.

Também estendemos esse padrão ao nosso cache de banco de dados (#26344). Usando verificações lookup-only, podemos pular a reconstrução do cache do DB quando ele já existe. Também simplificamos a chave do cache removendo o número do PR e o SHA, tornando-a muito mais reutilizável entre execuções.

Correção do cache

A correção do cache importa tanto quanto a velocidade do cache. Descobrimos que nosso padrão glob **/node_modules/ estava capturando apps/web/.next/node_modules/, que é a saída de build do Next.js contendo symlinks e arquivos gerados que mudam durante as builds. Isso levava a arquivos de cache corrompidos com erros de extração do tar (#26268). A correção foi um simples padrão de exclusão, mas encontrá-lo exigiu entender exatamente o que estava sendo armazenado em cache e por quê.

Também implementamos limpeza automática do cache (#26312). Quando um PR é mesclado ou fechado, agora deletamos os caches de build associados a ele. Isso mantém nosso armazenamento de cache enxuto e evita o acúmulo de entradas antigas que nunca seriam usadas novamente. Também simplificamos o formato da chave do cache no processo, removendo segmentos desnecessários como runner.os e node_version, que adicionavam complexidade sem melhorar as taxas de hit.

delete-cache-build-on-pr-close:
  if: github.event_name == 'pull_request' && github.event.action == 'closed'
  runs-on: blacksmith-2vcpu-ubuntu-2404
  env:
    CACHE_NAME: prod-build
  steps:
    - name: Delete cache-build cache
      uses: useblacksmith/cache-delete@v1
      with:
        key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
        prefix: "true"

Turbo Remote Caching

Atualizamos para Next.js 16 (#26093), o que reduziu nossas builds de 6+ minutos para ~1 minuto (salve para o time do Next.js pelas melhorias enormes). Com o Turbo Remote Caching ativado, builds subsequentes levam apenas 7 segundos para puxar do cache, em comparação com ~1 minuto para uma build completa.

Também ativamos o Turbo Remote Caching para nossos testes E2E da API v2 (#26331). Antes, cada shard de E2E reconstruía os pacotes da plataforma do zero. Agora cada shard se beneficia do cache remoto do Turbo, e otimizamos a compilação TypeScript do Jest ativando isolatedModules e desativando diagnósticos no CI, o que acelera bastante o tempo de inicialização dos testes.

Princípio 3: Escale os testes com sharding

Quando você tem mais de 1.100 casos de teste em 82 arquivos de teste E2E, executá-los em sequência é deixar desempenho na mesa. O sharding de testes, que divide a suíte entre vários runners paralelos, é a forma mais direta de reduzir o tempo total para suítes grandes. Já tínhamos sharding na nossa suíte E2E principal, mas aumentamos de 4 para 8 shards (#26342), o que reduziu o tempo total da suíte E2E em mais um minuto. Nossos testes E2E da API v2 ainda estavam rodando como um único job.

Dividimos nossos testes E2E da API v2 em 4 jobs paralelos usando a opção --shard nativa do Jest (#26183). Cada shard roda de forma independente com seus próprios serviços de Postgres e Redis, e os artefatos recebem nomes únicos por shard. O que antes levava mais de 10 minutos como um único job agora termina bem mais rápido com o trabalho distribuído entre quatro runners.

- name: Run Tests
  working-directory: apps/api/v2
  run: |
    yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
    EXIT_CODE=$?
    echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
    exit $EXIT_CODE

Fazer sharding em escala também revela problemas de infraestrutura que você talvez não note com execução sequencial. Quando vários jobs tentam popular o mesmo cache de banco de dados ao mesmo tempo, surgem condições de corrida. Resolvemos isso criando um job dedicado setup-db que roda antes de todos os jobs de teste E2E e de integração (#26171). Esse único job popula o cache do banco de dados uma vez, e todos os jobs subsequentes o restauram a partir dele. Sem corridas, sem trabalho duplicado.

Também criamos um workflow dedicado para testes unitários da API v2, separando-os da suíte principal de testes (#26189). Isso permite que eles rodem em paralelo com outras verificações, em vez de competir por recursos em um job de teste monolítico.

O efeito composto

Nenhuma dessas mudanças isoladamente teria reduzido nosso tempo de CI em 83%. Os ganhos vêm da combinação delas.

Dependências inteligentes fazem os jobs começarem mais cedo. Cache eficiente faz esses jobs gastarem menos tempo em configuração. Sharding faz a execução real dos testes acontecer em paralelo. Juntas, elas comprimem o caminho crítico de todos os lados.

O investimento se acumula com o tempo. Cada PR agora recebe feedback mais rápido. Os engenheiros permanecem em fluxo por mais tempo. A tentação de agrupar mudanças diminui, o que significa diffs menores e mais fáceis de revisar. A qualidade do código melhora porque o ciclo de feedback fica mais curto.

Vamos continuar investindo em performance de CI porque os retornos são reais e mensuráveis. O objetivo não é velocidade por si só; é permitir que os engenheiros entreguem software de qualidade mais rápido. Quando o CI é rápido, mover-se com agilidade e manter altos padrões trabalham juntos em vez de se atrapalharem.

Pull Requests referenciados

Base: migração de Buildjet para Blacksmith

  • #26247 - Migrar workflows do GitHub de Buildjet para Blacksmith

Estabilidade dos testes

  • #26269 - Corrigir testes E2E instáveis com sessões de usuário isoladas

  • #26278 - Estabilizar testes E2E com locators com escopo e agendas determinísticas

  • #26283 - Melhorar o isolamento de testes para testes E2E de tipos de evento gerenciados

Turbo Remote Caching

  • #26093 - Atualizar para Next 16 (builds reduzidas de 6+ minutos para ~1 minuto)

  • #26331 - Ativar o Turbo Remote Caching (builds subsequentes levam 7s)

Dependências de jobs e otimização de workflows

  • #26101 - Consolidar os jobs changes e check-label em prepare

  • #26170 - Desacoplar testes que não são da API v2 da build da API v2

  • #26157 - Tornar os jobs de relatório E2E não bloqueantes ao movê-los para um workflow separado

  • #26320 - Mover o job de deps para a etapa do job prepare para economizar ~20s por workflow

Use lookup-only

  • #26314 - Usar verificação de cache lookup-only para pular downloads do job de deps

  • #26060 - Pular a instalação do Playwright quando houver hit no cache

  • #26344 - Usar lookup-only para o cache do DB e simplificar a chave do cache

Correção do cache

  • #26268 - Excluir .next/node_modules do cache do yarn para evitar corrupção

  • #26312 - Deletar entradas do cache-build ao fechar PR

Sharding de testes e infraestrutura

  • #26342 - Aumentar os shards do E2E principal de 4 para 8 (economiza 1 minuto)

  • #26183 - Dividir os testes E2E da API v2 em 4 jobs paralelos

  • #26171 - Adicionar um job dedicado de setup-db para eliminar a condição de corrida do cache

  • #26189 - Criar um novo workflow para testes unitários da API v2

Keith Williams

Post by

Keith Williams

Head of Engineering