Soluções

Empresa

Cal.ai

Desenvolvedor

Recursos

Preços

Por

Keith Williams

01/01/2026

Como Reduzimos o Tempo de CI da Nossa Parede de 30 Minutos para 5 Minutos

Como Reduzimos o Tempo de CI da Nossa Parede de 30 Minutos para 5 Minutos

Como Reduzimos o Tempo de CI da Nossa Parede de 30 Minutos para 5 Minutos

Um CI lento é uma daquelas coisas que atrapalham o trabalho das equipas de engenharia. Os engenheiros esperam mais tempo por feedback, a troca de contexto aumenta e a tentação de agrupar alterações cresce, o que só torna as coisas piores. Passámos algumas semanas a otimizar os nossos fluxos de trabalho do GitHub Actions e reduzimos os nossos testes de PR de 30 minutos para 5 minutos. Isso representa uma redução de 83% no tempo de espera, alcançada sem sacrificar qualquer cobertura de teste ou critérios de qualidade.

Não houve uma solução mágica única. Observámos onde o tempo estava realmente a ser gasto e aplicámos três princípios: dependências inteligentes de trabalho, cache agressivo e sharding de testes. Aqui está o que aprendemos.

A Fundação: Migrando para Blacksmith

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

Assim que fizemos a atualização, notamos uma melhoria imediata de 2x na velocidade de recuperação de cache. Com o yarn install sendo 1,2GB, isso economiza cerca de 22 segundos em cada trabalho que precisa executar o yarn install, que é a maioria deles. Também estamos aproveitando o caching de contêiner do Blacksmith para serviços usados em muitos jobs (Postgres, Redis, Mailhog). O trabalho Iniciar contêineres agora está reduzido em 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

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

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

O que precisávamos corrigir

Os runners do Blacksmith expõem a contagem de CPU da máquina host em vez dos vCPUs alocados (tipicamente 4). O Playwright, sendo útil, geraria workers para corresponder, causando contenção de recursos e falhas intermitentes nos testes. Precisávamos apenas limitar os workers explicitamente com --workers=4 para corresponder à nossa alocação real.

Também descobrimos que um maior paralelismo expunha condições de corrida na nossa lógica de limpeza de testes. Testes de integração que funcionavam bem sequencialmente colidiriam ao serem executados simultaneamente, produzindo erros de idempotencyKey. A solução exigiu repensar como os testes limpam após si mesmos: consultar reservas por eventTypeId em vez de rastrear IDs de reservas individuais, que falhavam ao capturar reservas criadas indiretamente pelo código em teste (#26269, #26278, #26283).

Até nossos testes de captura de tela precisaram de ajuste (#26247). Diferentes runners têm sutis diferenças na renderização de fontes, então adicionamos deviceScaleFactor: 1 para garantir uma renderização consistente e aumentamos ligeiramente nosso limite de diferença de 0.05 para 0.07 para levar em conta variações de antialiasing sem mascarar regressões reais.

Mover mais rápido muitas vezes expõe acoplamentos ocultos em seu sistema. O acoplamento sempre esteve lá; você apenas não conseguia vê-lo em velocidades mais baixas. Corrigir esses problemas é o que possibilita melhorias sustentáveis na velocidade.

Princípio 1: Encaminhar 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. Quando seus testes E2E levam de 7 a 10 minutos, bloqueá-los em 2 a 3 minutos de verificação de lint e tipos simplesmente torna todo o fluxo de trabalho mais longo. O lint termina muito antes do E2E, então por que não deixá-los rodar em paralelo?

Desacoplar suítes de teste não relacionadas

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

Consolidar trabalhos pré-vôo

Tínhamos trabalhos separados para detecção de mudanças de arquivo (changes) e verificação de rótulos E2E (check-label). Cada trabalho tem um overhead de inicialização: girar um runner, verificar o código, restaurar contexto. Ao mesclar esses em um único trabalho prepare, eliminamos o overhead redundante (#26101). Fomos mais longe e movemos nossos passos de instalação de dependências para o trabalho prepare também, economizando mais 20 segundos por workflow ao eliminar mais um limite de trabalho (#26320).

Fazer relatórios não bloqueantes

Os relatórios de testes E2E são valiosos, mas não devem ficar no caminho crítico. Mudamos os trabalhos merge-reports, publish-report e cleanup-report para um workflow separado acionado por workflow_run (#26157). Agora os engenheiros podem reexecutar trabalhos falhados imediatamente sem esperar a geração do relatório ser concluída.

name: E2E Report

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

name: E2E Report

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

name: E2E Report

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

A regra é simples: seja flexível em permitir que os trabalhos E2E comecem a rodar mais cedo, em vez de mais tarde. Não bloqueie trabalhos de longa duração em pré-requisitos de curta duração, a menos que haja uma dependência genuína. O tempo total do seu CI é determinado pelo caminho crítico, e cada dependência desnecessária estende esse caminho.

Princípio 2: O Cache é Rei (Mas Apenas se Ele For Atingido)

Um cache eficaz requer chaves que invalidem quando devem, permaneçam estáveis quando devem e um armazenamento de cache que permaneça saudável ao longo do tempo.

Utilizar lookup-only

A nossa melhoria de cache mais dramática veio de uma observação simples: estávamos baixando aproximadamente 1.2GB de dados de cache mesmo quando não precisávamos instalar nada. O cache existia, a hit foi bem-sucedida, mas ainda estávamos pagando o custo do download antes de descobrir que poderíamos pular a etapa de instalação.

A correção foi usar verificações de cache lookup-only (#26314). Temos passos dedicados que rodam antes de todos os jobs para garantir que as dependências estejam instaladas, então, quando os jobs individuais rodam, sabemos que o cache existe. Antes de tentar restaurar o cache completo, primeiro verificamos se ele existe sem baixar. Se todos os caches têm hit, pulamos todo o fluxo de restauração e instalação. Nenhum download, nenhuma instalação, apenas prosseguir para o trabalho real. Isso sozinho economizou tempo significativo em todos os cenários de hit de cache, que, com um design de chave de cache adequado, deve 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
- 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
- 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 para a instalação do navegador Playwright (#26060). Como temos um passo dedicado que garante que os navegadores Playwright estejam em cache antes que os jobs de teste rodem, podemos usar a mesma abordagem lookup-only. Se o cache do Playwright tem hit, pule a instalação completamente. Agora verificamos primeiro e depois decidimos se devemos fazer o download.

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

Corretude do cache

A corretude do cache é tão importante 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 construção do Next.js contendo links simbólicos e arquivos gerados que mudam durante as construções. Isso 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 entender exatamente o que estava sendo armazenado em cache e por quê.

Também implementamos a limpeza automática de cache (#26312). Quando um PR é mesclado ou fechado, agora excluímos seus caches de build associados. Isso mantém nosso armazenamento de cache enxuto e evita o acúmulo de entradas obsoletas que nunca mais seriam usadas. Também simplificamos nosso formato de 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"
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"
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"

Cache Remoto Turbo

Atualizamos para o Next.js 16 (#26093), que reduziu nossas construções de 6+ minutos para ~1 minuto (parabéns para a equipe do Next.js pelas enormes melhorias). Com o cache remoto Turbo habilitado, construções subsequentes levam apenas 7 segundos para puxar do cache em comparação com o ~1 minuto para uma construção completa.

Também habilitamos o cache remoto Turbo para nossos testes E2E API v2 (#26331). Anteriormente, cada shard 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 habilitando isolatedModules e desabilitando diagnósticos no CI, o que acelera significativamente o tempo de inicialização dos testes.

Princípio 3: Escalar Testes com Sharding

Quando você tem mais de 1.100 casos de teste distribuídos em 82 arquivos de teste E2E, executá-los sequencialmente significa perder desempenho. O sharding de teste, que divide sua suíte de teste entre múltiplos runners paralelos, é a maneira mais direta de reduzir o tempo de execução total para grandes suítes de teste. Já tínhamos sharding em vigor para 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 sendo executados como um único trabalho.

Shardamos nossos testes E2E da API v2 em 4 jobs paralelos usando a opção embutida --shard do Jest (#26183). Cada shard é executado independentemente com seus próprios serviços Postgres e Redis, e os artefatos são nomeados de forma única por shard. O que antes levava mais de 10 minutos como um único trabalho agora é concluído significativamente 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
- 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
- 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 revela problemas de infraestrutura que você pode não notar com a execução sequencial. Quando múltiplos jobs tentam popular o mesmo cache de banco de dados simultaneamente, você tem condições de corrida. Resolvemos isso criando um trabalho dedicado setup-db que roda antes de todos os empregos E2E e de testes de integração (#26171). Este único trabalho popula o cache de banco de dados uma vez, e todos os jobs posteriores restauram a partir dele. Sem corridas, sem trabalho duplicado.

Também criamos um workflow dedicado para testes de unidade de API v2, separando-os da suíte de teste principal (#26189). Isso permite que eles rodem em paralelo com outras verificações, em vez de competir por recursos em um trabalho 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 permitem que os trabalhos comecem mais cedo. O caching eficaz significa que esses trabalhos passam menos tempo na configuração. O sharding significa que a execução real dos testes acontece em paralelo. Juntas, elas comprimem o caminho crítico de todas as direções.

O investimento se acumula ao longo do tempo. Cada PR agora recebe um feedback mais rápido. Os engenheiros permanecem em fluxo por mais tempo. A tentação de agrupar alterações diminui, o que significa diffs menores e mais revisáveis. A qualidade do código melhora porque o ciclo de feedback é mais apertado.

Continuaremos investindo no desempenho do CI porque os retornos são reais e mensuráveis. O objetivo não é a velocidade por si só; é permitir que os engenheiros entreguem software de qualidade mais rapidamente. Quando o CI é rápido, mover-se rapidamente e manter altos padrões trabalha em conjunto, em vez de contra a concorrência.

Pull Requests Referenciados

Fundação: Migração de Buildjet para Blacksmith

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

Estabilidade do Teste

  • #26269 - Corrigir testes e2e intermitentes com sessões de usuário isoladas

  • #26278 - Estabilizar testes e2e com localizadores escopados e cronogramas determinísticos

  • #26283 - Melhorar a isolação do teste para testes e2e de tipo de evento gerenciado

Cache Remoto Turbo

  • #26093 - Atualizar para Next 16 (redução das construções de 6+ minutos para ~1 minuto)

  • #26331 - Habilitar Cache Remoto Turbo (construções subsequentes levam 7s)

Dependências de Trabalho e Otimização de Workflow

  • #26101 - Consolidar mudanças e trabalhos de verificação de rótulos em prepare

  • #26170 - Desacoplar testes não relacionados à API v2 da construção da API v2

  • #26157 - Tornar trabalhos de relatórios E2E não bloqueantes ao mover para um workflow separado

  • #26320 - Mover trabalho de dependências para etapa do trabalho prepare para economizar ~20s por workflow

Utilizar lookup-only

  • #26314 - Usar verificação de cache lookup-only para pular downloads do trabalho de dependências

  • #26060 - Pular instalação do Playwright em caso de hit de cache

  • #26344 - Usar lookup-only para cache de DB e simplificar chave de cache

Corretude do Cache

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

  • #26312 - Excluir entradas de cache-build ao fechar PR

Sharding de Testes e Infraestrutura

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

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

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

  • #26189 - Criar novo workflow para testes de unidade da API v2

Comece com o Cal.com gratuitamente hoje!

Experimente uma programação e produtividade sem interrupções, sem taxas ocultas. Registe-se em segundos e comece a simplificar a sua programação hoje, sem necessidade de cartão de crédito!