Blog

Como reduzimos o tempo de espera da nossa CI de 30 minutos para 5 minutos

A CI lenta é daquelas coisas que irritam a equipa de engenharia. Os engenheiros esperam mais tempo por feedback, o contexto muda com mais frequência e aumenta a tentação de agrupar alterações, o que só piora as coisas. Passámos algumas semanas a otimizar os nossos workflows do GitHub Actions e reduzimos as nossas verificações de PR de 30 minutos para 5 minutos. Isso representa uma redução de 83% no tempo de espera, conseguida sem sacrificar qualquer cobertura de testes ou quality gates. Não houve uma solução mágica única. Olhámos para onde o tempo estava realmente a ser gasto e aplicámos três princípios: dependências de jobs inteligentes, caching agressivo e sharding de testes. Eis o que aprendemos.

Keith WilliamsJan 1, 2026

A base: migrar para Blacksmith

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

Depois de fazermos a atualização, notámos imediatamente uma melhoria de velocidade de 2x no descarregamento de cache. Como o yarn install pesa 1,2 GB, isso poupa cerca de 22 segundos em cada job que precisa de fazer yarn install, o que é a maioria. Também estamos a tirar partido do container caching do Blacksmith para serviços usados em muitos jobs (Postgres, Redis, Mailhog). O job Initialize containers agora está mais de 50% mais rápido, 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 de corrigir

Os runners do Blacksmith expõem o número de CPUs da máquina anfitriã em vez das vCPUs alocadas (normalmente 4). O Playwright, prestável como é, criava workers para corresponder a isso, causando contenção de recursos e falhas intermitentes nos testes. Bastou limitar explicitamente os workers com --workers=4 para corresponder à nossa alocação real.

Também descobrimos que mais paralelismo expunha condições de corrida na nossa lógica de limpeza de testes. Testes de integração que funcionavam bem em sequência colidiam quando corriam em simultâneo, produzindo erros idempotencyKey. A solução exigiu repensar como os testes se limpam a seguir: consultar bookings por eventTypeId em vez de seguir IDs individuais de bookings, o que falhava bookings criados indiretamente pelo código em teste (#26269, #26278, #26283).

Até os nossos testes de screenshots precisaram de ajustes (#26247). Runners diferentes têm pequenas diferenças na renderização de fontes, por isso adicionámos deviceScaleFactor: 1 para uma renderização consistente e aumentámos ligeiramente o nosso limiar de diff de 0.05 para 0.07 para ter em conta variações de antialiasing sem mascarar regressões reais.

Ir mais depressa muitas vezes expõe dependências ocultas no teu sistema. A dependência sempre lá esteve; só não a conseguias ver a velocidades mais baixas. Corrigir estes problemas é o que permite melhorias de velocidade sustentáveis.

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

Para nós, um grande problema era o lint estar a bloquear coisas que não devia bloquear. Quando os teus testes E2E demoram 7-10 minutos, bloqueá-los por causa de 2-3 minutos de lint e verificações de tipos só torna todo o workflow mais longo. O lint vai terminar muito antes do E2E, por isso porque não deixá-los correr em paralelo?

Desacopla suites de testes sem relação

Os nossos testes de integração e a suite principal de E2E não precisam da build da API v2, por isso removemos essa dependência (#26170). Em contrapartida, os testes E2E da API v2 não precisam da build principal da web. Cada suite de testes depende agora apenas do que realmente precisa, permitindo o máximo paralelismo.

Consolida jobs de preparação

Tínhamos jobs separados para deteção de alterações em ficheiros (changes) e verificação de labels de E2E (check-label). Cada job tem um custo de arranque: subir um runner, fazer checkout do código, restaurar o contexto. Ao fundirmos isto num único job prepare, eliminámos overhead redundante (#26101). Fomos mais longe e movemos também os passos de instalação de dependências para o job prepare, poupando mais 20 segundos por workflow ao eliminar mais um limite entre jobs (#26320).

Torna o reporting não bloqueante

Os relatórios de testes E2E são valiosos, mas não deviam estar no caminho crítico. Passámos os jobs merge-reports, publish-report e cleanup-report para um workflow separado acionado por workflow_run (#26157). Agora os engenheiros podem voltar a correr jobs com falha imediatamente, sem esperar que a geração do relatório termine.

name: E2E Report

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

A regra é simples: não tenhas problema em deixar os jobs E2E começarem mais cedo em vez de mais tarde. Não bloqueies jobs longos com pré-requisitos curtos a menos que exista uma dependência real. O tempo de espera da tua CI é determinado pelo caminho crítico, e cada dependência desnecessária alonga esse caminho.

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

Um caching eficaz precisa de chaves que invalidem quando devem, se mantenham estáveis quando devem e de um repositório de cache que se mantenha saudável ao longo do tempo.

Usa lookup-only

A nossa melhoria de caching mais dramática veio de uma observação simples: estávamos a descarregar aproximadamente 1,2 GB de dados em cache mesmo quando não precisávamos de instalar nada. O cache existia, o hit foi bem-sucedido, mas ainda estávamos a pagar o custo do download antes de descobrirmos que podíamos saltar o passo de instalação.

A correção foi usar verificações de cache lookup-only (#26314). Temos passos dedicados que correm antes de todos os jobs para garantir que as dependências estão instaladas, por isso, quando os jobs individuais começam, sabemos que o cache existe. Antes de tentar restaurar o cache completo, primeiro verificamos se ele existe sem fazer download. Se todos os caches tiverem hit, saltamos todo o fluxo de restauro e instalação. Sem downloads, sem instalações, apenas avançamos para o trabalho propriamente dito. Só isto poupou bastante tempo em todos os cenários de cache hit, o que, com um bom design de chaves de cache, deverá 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

Aplicámos a mesma lógica à instalação do browser do Playwright (#26060). Como temos um passo dedicado que garante que os browsers do Playwright estão em cache antes de os jobs de teste correrem, podemos usar a mesma abordagem lookup-only. Se o cache do Playwright acertar, saltamos totalmente a instalação. Agora verificamos primeiro e só depois decidimos se fazemos download ou não.

Estendemos este padrão também ao cache da nossa base de dados (#26344). Usando verificações lookup-only, podemos saltar a reconstrução do cache da BD quando ele já existe. Também simplificámos a chave de cache ao remover 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 é tão importante quanto a velocidade do cache. Descobrimos que o nosso padrão glob **/node_modules/ estava a capturar apps/web/.next/node_modules/, que é output de build do Next.js contendo symlinks e ficheiros gerados que mudam durante as builds. Isto levou a arquivos de cache corrompidos com erros de extração tar (#26268). A correção foi um simples padrão de exclusão, mas encontrá-lo exigiu perceber exatamente o que estava a ser armazenado em cache e porquê.

Também implementámos limpeza automática de cache (#26312). Quando um PR é fundido ou fechado, agora apagamos os caches de build associados. Isto mantém o nosso armazenamento de cache mais leve e evita a acumulação de entradas antigas que nunca mais seriam usadas. Simplificámos também o formato da nossa chave de cache no processo, removendo segmentos desnecessários como runner.os e node_version que acrescentavam 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

Atualizámos para Next.js 16 (#26093), o que reduziu as nossas builds de 6+ minutos para ~1 minuto (um grande agradecimento à equipa do Next.js pelas enormes melhorias). Com o Turbo Remote Caching ativado, as builds seguintes demoram apenas 7 segundos a puxar do cache, em comparação com ~1 minuto para uma build completa.

Também ativámos o Turbo Remote Caching para os nossos testes E2E da API v2 (#26331). Antes, cada shard de E2E reconstruía os packages da plataforma do zero. Agora cada shard beneficia do cache remoto do Turbo, e otimizámos a compilação TypeScript do Jest ao ativar isolatedModules e desativar diagnósticos na CI, o que acelera significativamente o tempo de arranque dos testes.

Princípio 3: escalar testes com sharding

Quando tens 1.100+ casos de teste espalhados por 82 ficheiros de teste E2E, corrê-los em sequência deixa desempenho em cima da mesa. O sharding de testes, que divide a tua suite de testes por vários runners paralelos, é a forma mais direta de reduzir o tempo de execução total para suites de testes grandes. Já tínhamos sharding na nossa suite E2E principal, mas aumentámo-lo de 4 para 8 shards (#26342) reduziu o tempo total da suite E2E em mais um minuto. Os nossos testes E2E da API v2 ainda estavam a correr como um único job.

Dividimos os testes E2E da API v2 em 4 jobs paralelos usando a opção --shard incorporada do Jest (#26183). Cada shard corre de forma independente com os seus próprios serviços Postgres e Redis, e os artefactos têm nomes únicos por shard. O que antes demorava 10+ minutos como um único job agora termina muito mais depressa com o trabalho distribuído por 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

O sharding em escala também revela problemas de infraestrutura que talvez não notes com execução sequencial. Quando vários jobs tentam povoar ao mesmo tempo o mesmo cache da base de dados, surgem condições de corrida. Resolvemos isto criando um job dedicado setup-db que corre antes de todos os jobs de teste E2E e de integração (#26171). Este único job popula o cache da base de dados uma vez, e todos os jobs descendentes fazem restore a partir dele. Sem corridas, sem trabalho duplicado.

Também criámos um workflow dedicado para os testes unitários da API v2, separando-os da suite principal de testes (#26189). Isto permite que corram em paralelo com outras verificações em vez de competirem por recursos num job de testes monolítico.

O efeito composto

Nenhuma destas alterações, isoladamente, teria reduzido o nosso tempo de CI em 83%. Os ganhos resultam da sua combinação.

Dependências inteligentes permitem que os jobs comecem mais cedo. Um caching eficaz significa que esses jobs passam menos tempo na preparação. Sharding significa que a execução real dos testes acontece em paralelo. Juntos, comprimem o caminho crítico de todos os lados.

O investimento acumula-se ao longo do tempo. Agora cada PR recebe feedback mais rápido. Os engenheiros mantêm-se em flow durante mais tempo. A tentação de agrupar alterações diminui, o que significa diffs mais pequenos e mais fáceis de rever. A qualidade do código melhora porque o ciclo de feedback é mais apertado.

Vamos continuar a investir no desempenho da CI porque os resultados são reais e mensuráveis. O objetivo não é a velocidade por si só; é permitir que os engenheiros entreguem software de qualidade mais depressa. Quando a CI é rápida, avançar depressa e manter padrões elevados funcionam em conjunto em vez de um contra o outro.

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 utilizador isoladas

  • #26278 - Tornar os testes e2e estáveis com localizadores limitados e schedules determinísticos

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

Turbo Remote Caching

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

  • #26331 - Ativar Turbo Remote Caching (as builds seguintes demoram 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 com a API v2 da build da API v2

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

  • #26320 - Mover o job deps para o passo do job prepare para poupar ~20s por workflow

Usa lookup-only

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

  • #26060 - Saltar a instalação do Playwright quando há hit no cache

  • #26344 - Usar lookup-only para o cache da BD e simplificar a chave de 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 é fechado

Sharding de testes e infraestrutura

  • #26342 - Aumentar os shards da suite E2E principal de 4 para 8 (poupa 1 minuto)

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

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

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

Keith Williams

Post by

Keith Williams

Head of Engineering