Soluções

Empresa

Cal.ai

Desenvolvedor

Recursos

Preços

Soluções

Empresa

Cal.ai

Preços

Por

Keith Williams

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 Base: Migrar para o Blacksmith

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

Assim que atualizámos, notámos uma melhoria imediata de 2x na velocidade para descarregar a cache. Como o install do yarn tem 1,2 GB, isso poupa cerca de 22 segundos em cada trabalho que precisa de executar o yarn install, o que acontece na maioria deles. Também estamos a tirar partido do caching de contentores do Blacksmith para serviços utilizados em muitos trabalhos (Postgres, Redis, Mailhog). O trabalho Initialize containers demora agora menos de metade do tempo, passando de 20 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 tivemos de corrigir

Os executores do Blacksmith expõem a contagem de CPUs da máquina anfitriã em vez dos vCPUs alocados (normalmente 4). O Playwright, tentando ajudar, criava processos de execução (workers) correspondentes, causando disputas por recursos e falhas de teste intermitentes (flaky). Bastou-nos limitar os workers explicitamente com --workers=4 para corresponder à nossa alocação real.

Também descobrimos que um maior paralelismo expunha condições de concorrência (race conditions) na nossa lógica de limpeza de testes. Os testes de integração que funcionavam bem sequencialmente colidiam quando executados simultaneamente, produzindo erros de idempotencyKey. A solução exigiu repensar a forma como os testes limpam os seus próprios dados: consultar reservas por eventTypeId em vez de monitorizar IDs de reserva individuais, que falhavam ao detetar reservas criadas indiretamente pelo código testado (#26269, #26278, #26283).

Até os nossos testes de captura de ecrã precisaram de ajustes (#26247). Executores diferentes apresentam subtis diferenças na renderização de tipos de letra, pelo que adicionámos deviceScaleFactor: 1 para uma renderização consistente e aumentámos ligeiramente o nosso limiar de diferença de 0.05 para 0.07 para acomodar variações de suavização (antialiasing) sem mascarar regressões reais.

Aumentar a velocidade expõe frequentemente dependências ocultas no sistema. O acoplamento sempre lá esteve; simplesmente não era visível a velocidades mais baixas. Corrigir estes problemas é o que permite melhorias de velocidade sustentáveis.

Princípio 1: Encurtar o Caminho Crítico com o Design de Dependências

Para nós, um grande problema era o linter estar a bloquear etapas que não devia. Quando os testes E2E demoram 7 a 10 minutos, bloqueá-los por causa de 2 a 3 minutos de linter e verificações de tipos apenas prolonga todo o fluxo de trabalho. O linter terminará muito antes do E2E, então porque não deixá-los correr em paralelo?

Desacoplar suites de testes não relacionadas

Os nossos testes de integração e a suite principal de E2E não precisam da compilação da API v2, pelo que removemos essa dependência (#26170). Por outro lado, os testes E2E da API v2 não precisam da compilação web principal. Cada suite de testes depende agora apenas do que realmente necessita, permitindo o máximo paralelismo.

Consolidar trabalhos preliminares (preflight)

Tínhamos trabalhos separados para deteção de alterações de ficheiros (changes) e verificação de etiquetas E2E (check-label). Cada trabalho tem um custo de inicialização: iniciar um executor, descarregar o código, restaurar o contexto. Ao fundir estes num único trabalho de preparação (prepare), eliminámos esse custo redundante (#26101). Fomos mais longe e movemos também os passos de instalação de dependências para o trabalho de prepare, poupando mais 20 segundos por fluxo de trabalho ao eliminar mais um limite de trabalho (#26320).

Tornar os relatórios não bloqueantes

Os relatórios de testes E2E são valiosos, mas não devem estar no caminho crítico. Movemos os trabalhos merge-reports, publish-report e cleanup-report para um fluxo de trabalho separado acionado por workflow_run (#26157). Agora os engenheiros podem reiniciar imediatamente os trabalhos que falharam sem ter de esperar pela conclusão da geração do relatório.

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 ao permitir que os trabalhos E2E comecem a correr o quanto antes. Não bloqueie trabalhos de longa duração com pré-requisitos rápidos, a menos que exista uma dependência real. O tempo total do seu CI é determinado pelo caminho crítico, e cada dependência desnecessária prolonga esse caminho.

Princípio 2: O Caching é Rei (Mas Apenas se Funcionar)

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

Tirar partido do lookup-only

A nossa melhoria de caching mais marcante surgiu de uma observação simples: estávamos a descarregar cerca de 1,2 GB de dados em cache mesmo quando não precisávamos de instalar nada. A cache existia, a correspondência foi conseguida, mas continuávamos a pagar o custo do download antes de descobrir que podíamos ignorar o passo de instalação.

A correção foi utilizar verificações de cache do tipo lookup-only (#26314). Temos passos dedicados que correm antes de todos os trabalhos para garantir que as dependências estão instaladas, por isso, no momento em que os trabalhos individuais arrancam, sabemos que a cache existe. Antes de tentar restaurar a cache completa, verificamos primeiro se ela existe sem a descarregar. Se houver correspondência em todas as caches, ignoramos todo o fluxo de restauro e instalação. Sem downloads, sem instalações, avançando diretamente para o trabalho real. Só isto poupou imenso tempo em todos os cenários de acerto na cache, o que, com uma chave de cache bem desenhada, 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

Aplicámos a mesma lógica à instalação de navegadores do Playwright (#26060). Como temos um passo dedicado que garante que os navegadores do Playwright estão guardados em cache antes de os trabalhos de teste correrem, podemos usar a mesma abordagem de lookup-only. Se houver acerto na cache do Playwright, ignoramos completamente a instalação. Agora verificamos primeiro e depois decidimos se sequer fazemos o download.

Estendemos também este padrão à nossa cache da base de dados (#26344). Usando verificações de lookup-only, podemos evitar reconstruir a cache da BD quando esta já existe. Além disso, simplificámos a chave de cache removendo o número do PR e o SHA, tornando-a muito mais reutilizável entre execuções.

Integridade da cache

A integridade da cache importa tanto quanto a sua velocidade. Descobrimos que o nosso padrão global **/node_modules/ estava a capturar apps/web/.next/node_modules/, que é um ficheiro de saída da compilação do Next.js contendo links simbólicos (symlinks) e ficheiros gerados que mudam durante as compilações. Isto 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 descobri-la exigiu compreender exatamente o que estava a ser guardado na cache e porquê.

Também implementámos a limpeza automática da cache (#26312). Quando um PR é fundido (merged) ou fechado, apagamos as caches de compilação associadas ao mesmo. Isto mantém o nosso armazenamento de cache limpo e evita a acumulação de registos antigos que nunca mais seriam utilizados. Simplificámos o formato da nossa chave de cache no processo, removendo segmentos desnecessários como runner.os e node_version que adicionavam complexidade sem melhorar a taxa de acerto.

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"

Turbo Remote Caching

Atualizámos para o Next.js 16 (#26093), o que reduziu as nossas compilações de mais de 6 minutos para cerca de 1 minuto (um agradecimento à equipa do Next.js pelas enormes melhorias). Com o Turbo Remote Caching ativado, as compilações seguintes demoram apenas 7 segundos a descarregar da cache, em comparação com cerca de 1 minuto para uma compilação completa.

Também ativámos o Turbo Remote Caching nos testes E2E da API v2 (#26331). Anteriormente, cada fração (shard) de E2E reconstruía os pacotes da plataforma do zero. Agora, cada fração beneficia da cache remota do Turbo, e otimizámos a compilação do TypeScript do Jest ativando a opção isolatedModules e desativando os diagnósticos no CI, o que acelera significativamente o arranque dos testes.

Princípio 3: Escalar Testes com Fragmentação (Sharding)

Quando tem mais de 1100 casos de teste em 82 ficheiros de teste E2E, executá-los de forma sequencial é desperdiçar desempenho. A fragmentação de testes, que divide a sua suite de testes por múltiplos executores paralelos, é a forma mais direta de reduzir o tempo de execução real em grandes suites de testes. Já tínhamos a fragmentação implementada na nossa suite de E2E principal, mas aumentámos de 4 para 8 frações (shards) (#26342), o que reduziu o tempo total da suite de E2E em mais um minuto. Os nossos testes E2E da API v2 ainda corriam como um único trabalho.

Fragmentámos os nossos testes E2E da API v2 em 4 trabalhos paralelos utilizando a opção nativa --shard do Jest (#26183). Cada fração corre de forma independente com os seus próprios serviços Postgres e Redis, e os artefactos têm nomes únicos por fração. O que antes demorava mais de 10 minutos num único trabalho agora conclui-se de forma significativamente mais rápida com o trabalho distribuído por quatro executores.

- 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

A fragmentação em grande escala também expõe problemas de infraestrutura que podem passar despercebidos com uma execução sequencial. Quando vários trabalhos tentam preencher a mesma cache da base de dados em simultâneo, ocorrem condições de concorrência. Resolvemos isto criando um trabalho dedicado setup-db que corre antes de todos os trabalhos de testes E2E e de integração (#26171). Este trabalho único preenche a cache da base de dados uma vez e todos os trabalhos seguintes restauram a partir dela. Sem concorrência, sem trabalho duplicado.

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

O Efeito Cumulativo

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

Dependências inteligentes permitem que os trabalhos comecem mais cedo. Um caching eficaz significa que esses trabalhos perdem menos tempo na configuração. A fragmentação garante que a execução real dos testes acontece em paralelo. Juntos, comprimem o caminho crítico em todas as direções.

O investimento traduz-se em benefícios cumulativos ao longo do tempo. Cada PR recebe agora feedback mais rápido. Os engenheiros mantêm-se concentrados durante mais tempo. A tentação de acumular alterações diminui, o que resulta em diffs menores e mais fáceis de rever. A qualidade do código melhora porque o ciclo de feedback é mais estreito.

Continuaremos a investir no desempenho do CI porque os retornos são reais e mensuráveis. O objetivo não é a velocidade pela velocidade; é permitir que os engenheiros disponibilizem software de qualidade mais depressa. Quando o CI é rápido, trabalhar com agilidade e manter padrões elevados funcionam em harmonia em vez de se oporem.

Pull Requests Referenciados

Base: Migração de Buildjet para Blacksmith

  • #26247 - Migrate GitHub workflows from Buildjet to Blacksmith

Estabilidade de Testes

  • #26269 - Fix flaky e2e tests with isolated user sessions

  • #26278 - Stabilize e2e tests with scoped locators and deterministic schedules

  • #26283 - Improve test isolation for managed event type e2e tests

Turbo Remote Caching

  • #26093 - Upgrade to Next 16 (builds reduced from 6+ minutes to ~1 minute)

  • #26331 - Enable Turbo Remote Caching (subsequent builds take 7s)

Dependências de Trabalhos e Otimização de Fluxos de Trabalho

  • #26101 - Consolidate changes and check-label jobs into prepare

  • #26170 - Decouple non-API v2 tests from API v2 build

  • #26157 - Make E2E report jobs non-blocking by moving to separate workflow

  • #26320 - Move deps job to prepare job step to save ~20s per workflow

Tirar partido do lookup-only

  • #26314 - Use lookup-only cache check to skip deps job downloads

  • #26060 - Skip Playwright install on cache hit

  • #26344 - Use lookup-only for DB cache and simplify cache key

Integridade da cache

  • #26268 - Exclude .next/node_modules from yarn cache to prevent corruption

  • #26312 - Delete cache-build cache entries on PR close

Fragmentação de Testes e Infraestrutura

  • #26342 - Increase main E2E shards from 4 to 8 (saves 1 minute)

  • #26183 - Shard API v2 E2E tests into 4 parallel jobs

  • #26171 - Add dedicated setup-db job to eliminate cache race condition

  • #26189 - Create new workflow for API v2 unit tests

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!