Blog

Como reduzimos o tempo total do nosso CI de 30 minutos para 5 minutos

CI lento é uma daquelas coisas que tiram o engenho da equipe de engenharia. As pessoas da engenharia esperam mais pelo feedback, trocam de contexto com mais frequência e a tentação de agrupar mudanças aumenta, o que só piora tudo. Passamos algumas semanas otimizando nossos fluxos de trabalho do GitHub Actions e reduzimos nossos checks de PR de 30 minutos para 5 minutos. Isso é uma redução de 83% no tempo de espera, alcançada sem sacrificar nenhuma cobertura de teste nem os quality gates. Não houve uma única solução mágica. Olhamos para onde o tempo realmente estava indo e aplicamos três princípios: dependências inteligentes de jobs, cache agressivo e sharding de testes. Aqui está o que aprendemos.

Keith WilliamsJan 1, 2026

A base: migrando para Blacksmith

Na verdade, já tínhamos tentado migrar para Blacksmith antes, mas tivemos 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 tranquila (#26247): substituímos os runners buildjet-*vcpu-ubuntu-2204 por blacksmith-*vcpu-ubuntu-2404 em 28 arquivos de workflow, trocamos as actions cache e setup-node e ajustamos 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, percebemos um ganho imediato de 2x na velocidade de recuperação do cache. Como o yarn install pesa 1,2 GB, isso economiza cerca de 22 segundos em cada job que precisa rodar yarn install, e isso é a maioria deles. Também estamos aproveitando o container caching do Blacksmith para serviços usados em vários jobs (Postgres, Redis, Mailhog). O job Initialize containers caiu mais de 50%, 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 contagem de CPUs da máquina host em vez das vCPUs alocadas (normalmente 4). O Playwright, querendo ajudar, criava workers para corresponder a isso, o que causava disputa por recursos e testes instáveis. Só precisávamos limitar os workers explicitamente com --workers=4 para corresponder à nossa alocação real.

Também descobrimos que uma paralelização maior expôs condições de corrida na nossa lógica de limpeza de testes. Testes de integração que funcionavam bem em sequência passavam a colidir quando rodavam ao mesmo tempo, gerando erros de idempotencyKey. A solução exigiu repensar como os testes limpam o que criam: consultar bookings por eventTypeId em vez de rastrear IDs individuais de booking, o que deixava passar bookings criados indiretamente pelo código sob teste (#26269, #26278, #26283).

Até nossos testes de screenshot 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 um pouco nosso limite de diff de 0.05 para 0.07 para lidar com variações de antialiasing sem mascarar regressões reais.

Mover mais rápido muitas vezes expõe acoplamentos ocultos no seu sistema. O acoplamento sempre esteve lá; você só não conseguia vê-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 o lint estar bloqueando coisas que não deveria bloquear. Quando seus testes de E2E levam 7-10 minutos, travá-los por causa de 2-3 minutos de lint e checagens de tipos só alonga todo o workflow. O lint termina muito antes dos E2E, então por que não deixá-los rodar em paralelo?

Desacople suítes de teste sem relação

Nossos testes de integração e a suíte principal de E2E não precisam do build da API v2, então removemos essa dependência (#26170). Por outro lado, os testes de E2E da API v2 não precisam do 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 checagem 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 tudo em um único job prepare, eliminamos overhead redundante (#26101). Fomos além e também movemos nossas etapas de instalação de dependências para o job prepare, economizando mais 20 segundos por workflow ao eliminar mais uma fronteira entre jobs (#26320).

Faça o reporting não bloqueante

Os 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 a equipe de engenharia pode rerodar 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 deixar os jobs de E2E começarem a rodar mais cedo, em vez de mais tarde. Não bloqueie jobs de longa duração por pré-requisitos curtos, a menos que exista uma dependência real. O tempo de parede 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)

Um cache eficaz exige chaves que invalidem quando devem, permaneçam estáveis quando devem e um store de cache que continue saudável com o 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 era bem-sucedido, mas ainda assim estávamos pagando o custo de download antes de descobrir que poderíamos pular a etapa de instalação.

A correção foi usar checagens 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, já sabemos que o cache existe. Antes de tentar restaurar o cache completo, primeiro verificamos se ele existe sem baixar nada. Se todos os caches derem hit, pulamos o fluxo inteiro de restore e install. Sem downloads, sem instalações, direto para o trabalho real. Só isso economizou tempo significativo em todo cenário de cache hit, o que, com um bom design de chave de cache, deveria acontecer na 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 der hit, pulamos a instalação por completo. Agora primeiro verificamos e só depois decidimos se vamos baixar alguma coisa.

Estendemos esse padrão também ao cache do nosso banco de dados (#26344). Usando checagens lookup-only, conseguimos 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 os builds. Isso levava a arquivos de cache corrompidos com erros de extração do tar (#26268). A correção foi um padrão de exclusão simples, mas encontrá-lo exigiu entender exatamente o que estava sendo armazenado em cache e por quê.

Também implementamos limpeza automática de cache (#26312). Quando um PR é mesclado ou fechado, agora apagamos os caches de build associados a ele. Isso mantém nosso store de cache enxuto e evita o acúmulo de entradas antigas que nunca seriam usadas de novo. Também simplificamos o formato da chave de 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 o Next.js 16 (#26093), o que reduziu nossos builds de 6+ minutos para ~1 minuto (um salve para o time do Next.js pelas melhorias enormes). Com o Turbo Remote Caching ativado, os builds seguintes levam só 7 segundos para buscar do cache, em vez de ~1 minuto para um build completo.

Também ativamos o Turbo Remote Caching para nossos testes de 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 ao habilitar isolatedModules e desativar diagnósticos no CI, o que acelera significativamente o tempo de início dos testes.

Princípio 3: escale testes com sharding

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

Dividimos os testes de 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 artifacts 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

Sharding em escala também expõe problemas de infraestrutura que você talvez não perceba 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 de E2E e integração (#26171). Esse único job popula o cache do banco uma vez, e todos os jobs seguintes fazem restore a partir dele. Sem corrida, 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 checagens, 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%. O ganho vem da combinação delas.

Dependências inteligentes fazem os jobs começarem mais cedo. Cache eficaz significa que esses jobs gastam menos tempo em setup. Sharding significa que a execução real dos testes acontece em paralelo. Juntas, essas mudanças comprimem o caminho crítico de todos os lados.

O investimento se compõe ao longo do tempo. Cada PR agora recebe feedback mais rápido. A equipe de engenharia fica em flow 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 loop de feedback fica mais apertado.

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 a equipe de engenharia entregue software de qualidade mais rápido. Quando o CI é rápido, avançar com agilidade e manter altos padrões passam a trabalhar juntos, em vez de um contra o outro.

Pull Requests citados

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 localizadores escopados e schedules determinísticos

  • #26283 - Melhorar o isolamento de testes para testes E2E de managed scheduling link

Turbo Remote Caching

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

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

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

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

  • #26170 - Desacoplar testes não relacionados à API v2 do build da API v2

  • #26157 - Tornar os jobs de relatório de 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 checagem de cache com 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 - Apagar entradas de cache-build quando o PR for fechado

Sharding de testes e infraestrutura

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

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

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

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

Keith Williams

Post by

Keith Williams

Head of Engineering