skip to content
Tessel

Search

Turbo Remote Cache: CI de 18min pra 1min

9 min read

Como o CI do monorepo caiu de 18min pra 1min com Turbo Remote Cache, --affected e matrix jobs.

Turbo Remote Cache: como o CI do monorepo caiu de 18min pra 1min

Esse post documenta a migração de CI do Tessel — um monorepo pnpm com 24 pacotes (apps Next.js, workers Cloudflare, libraries internas) — pra um modelo com turbo run --affected + Turbo Remote Cache. O resultado mensurado: 18min37s → 1min11s em runs warm, com um setup que demorou cerca de duas horas e tem alguns caveats que vale registrar.

A TL;DR honesta: o ganho não veio do --affected sozinho, e na verdade quase não veio do remote cache também — até a gente perceber que o cache estava silenciosamente desligado por uma variável vazia. A história dessa armadilha é metade do post.

O ponto de partida

O CI rodava em uma única job sequencial: install → lint → build all → type-check all → test all. Tempo médio: 18 minutos. Pra cada PR, mesmo que tocasse um único pacote, todos os 24 entravam na esteira.

A solução tradicional pra isso é matrix + filters — rodar só os pacotes afetados. O Turborepo 2.x já oferece --affected baseado em git diff e dependency graph. A sequência foi:

  1. Migrar deploy de N workflows individuais (deploy-admin.yml, deploy-bot.yml, …) pra um único orchestrator (deploy-affected.yml) que chama turbo run deploy --affected --dry-run=json e gera uma matrix de pacotes pra deploy.
  2. Adicionar Turbo Remote Cache via Vercel pra reusar artefatos entre runs.

A primeira parte foi mecânica e funcionou de imediato. A segunda parece simples na documentação (TURBO_TOKEN + TURBO_TEAM no env) e foi onde a gente quase saiu errado.

O orchestrator único

Antes:

.github/workflows/
├── deploy-admin.yml
├── deploy-bot.yml
├── deploy-workers.yml
├── deploy-upload-service.yml
├── ... (mais 13)

Cada workflow tinha um paths: filter próprio. Pushar um PR que mudava o pnpm-lock.yaml disparava 17 deploys em paralelo — caro, ruidoso, e cada um repetia install/build do zero.

O substituto é um workflow só, que detecta o que mudou e gera matrix:

jobs:
  detect:
    outputs:
      packages: ${{ steps.affected.outputs.packages }}
      has-affected: ${{ steps.affected.outputs.has-affected }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Detect affected packages
        id: affected
        run: |
          AFFECTED_JSON=$(pnpm exec turbo run deploy --affected --dry-run=json 2>/dev/null || echo '{"tasks":[]}')
          PACKAGES=$(echo "$AFFECTED_JSON" | jq -c '[.tasks[] |
            select(.taskId | endswith("#deploy")) |
            select(.command != "<NONEXISTENT>") |
            .package] | unique')
          echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
 
  deploy:
    needs: detect
    if: needs.detect.outputs.has-affected == 'true'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.packages) }}
    steps:
      - run: pnpm exec turbo run build --filter=${{ matrix.package }}^...
      - run: pnpm --filter=${{ matrix.package }} run --if-present type-check
      - run: pnpm --filter=${{ matrix.package }} run --if-present test
      - run: pnpm --filter=${{ matrix.package }} run deploy

Dois detalhes que custam tempo se você descobrir tarde:

1. pnpm --filter X deploypnpm --filter X run deploy. O primeiro é interpretado como o subcomando builtin pnpm deploy (que extrai um pacote pra um diretório standalone) e dá ERR_PNPM_INVALID_DEPLOY_TARGET. O segundo invoca o npm script. Tive que substituir em 13 workflows depois do primeiro deploy falhar.

2. select(.command != "<NONEXISTENT>") é necessário. O turbo --dry-run lista todos os pacotes que teriam uma task deploy, inclusive os que herdaram a task da config global mas não definem o script. Sem o filtro, a matrix tenta deployar pacotes que não têm script de deploy.

A armadilha do cache silenciosamente desligado

Com o orchestrator funcionando, adicionei o remote cache:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Configurei TURBO_TOKEN como secret. Subi o PR. Os runs começaram a vir mais rápidos — 18min → 6min. Vitória declarada. Postei no Slack: “remote cache ativo”.

Não estava ativo.

A pista veio quando alguém perguntou “quanto otimizamos com o cache?”. Pra responder com número, fui ler o log e procurei cache hit:

test-and-lint  Build all packages  cache miss, executing 5352a88e5a20c90e
test-and-lint  Build all packages  cache miss, executing c9aa709f399a688b
test-and-lint  Build all packages  cache miss, executing a5c98db22de0510d
... (24 cache miss, 0 cache hit)

Todos os 24 builds eram cache miss. Olhei o env do step:

TURBO_TOKEN: ***
TURBO_TEAM:

TURBO_TEAM vazio. O Turbo silenciosamente pula o remote cache quando uma das duas variáveis falta — sem warning, sem erro, sem nada nos logs além de “cache miss”. Eu tinha criado TURBO_TOKEN como secret e esquecido de criar TURBO_TEAM como variable.

E os 6 minutos? Vinham de outra coisa: as mudanças recentes só tocavam workflow files, e o passo “Detect changes” do CI categorizava como global=false, então pulava Build all packages (global change) e ia direto pro step final unconditional Build all packages — que rodava com cache local da Turbo (dentro do mesmo runner) e dedupava parte do trabalho. O speedup era artefato de escopo de mudança, não de cache cross-run.

A lição operacional: sempre meça o que você acha que otimizou. Se eu tivesse rodado dois pushes idênticos seguidos (mesma SHA da fonte, build determinístico), o segundo deveria ter sido instantâneo. Não foi. Era óbvio em retrospecto.

O setup correto

Pra ativar o remote cache via Vercel:

  1. Pegar o team slug do Vercel (não o ID — o slug é o que vai em TURBO_TEAM):
# via Vercel MCP ou REST API:
curl https://api.vercel.com/v2/teams \
  -H "Authorization: Bearer $VERCEL_TOKEN" | jq '.teams[] | {name, slug}'
# → "journeystudios"
  1. Criar um token no Vercel com scope no team correto.

  2. Adicionar como GitHub repo configs:

gh secret set TURBO_TOKEN --body "$VERCEL_TOKEN"
gh variable set TURBO_TEAM --body "journeystudios"

A distinção secret/variable importa: o token vai em secrets (sensível), o slug vai em variables (público, e expor em logs ajuda debug).

  1. Garantir que TURBO_TOKEN e TURBO_TEAM estão no env de todos os jobs que executam tasks turbo, não só no step específico:
jobs:
  test-and-lint:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Job-level, não step-level. A Turbo lê na inicialização do CLI; configurar só no step de build deixa os steps anteriores (que também invocam turbo) sem cache.

A medição real

Com TURBO_TEAM=journeystudios configurado, fiz dois runs idênticos via workflow_dispatch:

Cold run (cache vazio, populando):

Total: 03:46:54 → 03:53:06  (6m12s)
Build all packages: ~5min, 24/24 "cache miss, executing"

Warm run (mesma SHA, sem mudanças):

Total: 03:53:52 → 03:55:03  (1m11s)
Build all packages:
  cache hit, replaying logs 5352a88e5a20c90e
  cache hit, replaying logs c9aa709f399a688b
  ... (24/24 cache hit)
  Tasks:    24 successful, 24 total
   Time:    3.299s >>> FULL TURBO

3.3 segundos pra “buildar” 24 pacotes. O >>> FULL TURBO é literal — Turbo imprime quando 100% das tasks foram cacheadas e não houve execução real, só replay de logs salvos.

Comparativo:

RunEstadoTempo totalBuild step
Pré-cache (PR 46)sem remote cache~18m37s~14min
Cold (populando)TURBO_TEAM ok6m12s~5min
Warm (FULL TURBO)reuso completo1m11s3.3s

Redução do build step: 14min → 3s (~99.6%). Redução do CI total: 18min → 1min (~94%).

Os outros ~67s do warm run são install do pnpm (cache de tarball já vinha do actions/cache), restore do node_modules, e setup do runner — overhead que o Turbo não consegue eliminar.

Onde o cache não ajuda

Pra calibrar expectativas:

Mudanças globais resetam tudo. turbo.json, package.json da raiz, pnpm-lock.yaml, tsconfig.base.json, biome.json — qualquer um desses muda o hash de input de praticamente todas as tasks. Cold de novo.

Cache miss em pacotes intermediários cascateia. Se você muda código em @tessel/utils, todos os pacotes que dependem dele (~15 dos 24) viram cache miss. O cache é por hash de input, e mudança transitiva muda input.

Tasks com side effect não devem ser cacheadas. deploy é o exemplo claro — não dá pra “replay” o output de wrangler deploy. No turbo.json:

"tasks": {
  "deploy": {
    "cache": false,
    "dependsOn": ["build", "type-check"]
  }
}

A inferência aqui é que cacheamos o que é determinístico (build, lint, type-check, test) e deixamos que deploy sempre execute. O ganho é o dependsOn — se o build veio do cache, a parte cara já passou.

Logs de tasks cacheadas vêm do replay, não do run atual. Isso confunde debugging. Se você está caçando um bug de build, force --force ou apague o cache local antes de assumir que o output que apareceu é o atual.

Custo

Vercel Remote Cache na conta hobby/pro: gratuito pra free, com limites generosos (200GB/mês de transferência no Pro). O Tessel está bem dentro do free tier — os artefatos cacheados de 24 builds + tests + type-checks somam alguns MB por hash, e a TTL padrão de 7 dias mantém o working set pequeno.

GitHub Actions: a fatura caiu junto. Antes, cada PR queimava ~18min de runner (concurrent jobs incluídos). Agora a maioria dos PRs warm queima ~1min. Pra um time de 3 devs com ~50 PRs/mês, é diferença real no minuto-runner.

O que mudou em DX

O efeito mais visível não é o “1 minuto” no badge do CI — é a previsibilidade. Quando o engenheiro abre PR e vê o CI pendente, antes era “vou fazer outra coisa por 20 minutos”. Agora é “vou esperar”. A janela de feedback caiu pra dentro do contexto da revisão, e a tentação de mergear sem CI verde sumiu.

O segundo efeito é a confiança em rebasear. Antes, rebase = nova rodada de 18min. Agora, se o rebase não introduz mudanças semanticamente diferentes, o CI volta em 1-2min. Reduz a fricção de manter PRs em sync com main.

Lições gerais

  1. Mensure antes de declarar vitória. “O CI ficou mais rápido” sem cache hit no log não é evidência de cache funcionando. É evidência de que algo mudou — pode ser o cache, pode ser escopo de mudança, pode ser um runner mais rápido. Confirme com o sinal direto.

  2. Falhas silenciosas em ferramentas de build são caras. Turbo poderia logar “WARN: TURBO_TEAM not set, remote cache disabled”. Não loga. Outros build tools fazem o mesmo. A defesa é configurar um teste de smoke: rodar o build duas vezes em sequência e exigir que a segunda mostre >>> FULL TURBO ou cache hit.

  3. Variáveis vs secrets em GitHub Actions importam. Variáveis aparecem nos logs (TURBO_TEAM: journeystudios), secrets viram ***. Pra debug de “está configurado?”, coloque tudo que não é sensível em variables — você vai querer ler o valor um dia.

  4. Matrix + filter + remote cache se compõem. Cada otimização sozinha tem ganho marginal. Combinadas, multiplicam: matrix paraleliza, --affected reduz o conjunto, remote cache reusa o que já foi feito. O passo certo é aplicar nessa ordem (paralelizar > filtrar > cachear), porque matrix sem filtro é caro e cache sem paralelo perde os ganhos triviais.

  5. Cache key é tudo. Se a hash do Turbo inclui um arquivo que muda toda hora (timestamp gerado, version.generated.ts não-determinístico, env var de CI no input), o cache é inútil. Vale auditar turbo.json regularmente pra garantir que inputs e env estão restritos ao que realmente afeta o output.

Fechamento

A migração demorou cerca de duas horas de trabalho real e uma hora extra debugando a variável esquecida. O retorno é diluído ao longo de cada PR daqui pra frente — provavelmente ~15min economizados por desenvolvedor por dia, em média. Pra um setup de monorepo grande, é dos investimentos com melhor ROI que existem em DevOps de pacote.

Se você está construindo um monorepo Turbo + GitHub Actions em 2026: comece com matrix --affected, ative remote cache no primeiro dia, e desde o início rode dois workflow_dispatch consecutivos pra confirmar que o segundo é FULL TURBO. É o smoke test que evita declarar vitória cedo demais.