95% Mais Rápido no CI Com 15 Linhas de Bash

Erik Treviño avatar
Erik Treviño
Cover for 95% Mais Rápido no CI Com 15 Linhas de Bash

Reduzi o tempo de validação do nosso CI em 95%.

Não foi com cache. Não foi com sharding. Não foi com uma ferramenta third-party sofisticada que adiciona mais uma dependência ao seu pipeline.

Foi com 15 linhas de bash. Zero dependências. Zero risco na cadeia de suprimentos.

O PR que entregou essa mudança era menor que a maioria das mensagens de commit. E economiza para o time 7-8 horas por mês.

O Problema

Todo PR rodava o pipeline completo de CI. Linting do backend, testes unitários do backend, testes unitários do frontend, testes E2E — a maratona inteira. Um desenvolvedor altera um único arquivo de teste E2E, faz push da branch e espera 8 minutos e 18 segundos para o linting do backend dizer que o código Python está tudo certo.

O código Python que ele nem tocou.

Esse é o comportamento padrão da maioria das configurações de CI. Um único arquivo de workflow, um único trigger, roda tudo em todo push. É simples. É seguro. E também está queimando tempo do desenvolvedor em validações que não têm como falhar para as mudanças que foram feitas.

A Solução: 15 Linhas de Bash

A ideia é simples: usar git diff --name-only para determinar quais arquivos mudaram e, condicionalmente, pular jobs que são irrelevantes para essas mudanças.

#!/bin/bash
# detect-changes.sh — Determine which CI jobs to run based on changed files
# Called by GitHub Actions workflow to set output variables

CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)

RUN_BACKEND=false
RUN_FRONTEND=false
RUN_E2E=false

for file in $CHANGED_FILES; do
  case "$file" in
    backend/*|api/*|*.py)        RUN_BACKEND=true ;;
    frontend/*|src/*|*.ts|*.tsx) RUN_FRONTEND=true ;;
    tests/e2e/*|playwright/*)    RUN_E2E=true ;;
    .github/*|docker-compose*|Makefile)
      # Infrastructure changes: run everything as a safety net
      RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;
  esac
done

echo "run-backend=$RUN_BACKEND" >> "$GITHUB_OUTPUT"
echo "run-frontend=$RUN_FRONTEND" >> "$GITHUB_OUTPUT"
echo "run-e2e=$RUN_E2E" >> "$GITHUB_OUTPUT"

É isso. O script lê o diff, classifica cada arquivo alterado por diretório e define flags de saída que os jobs downstream verificam antes de executar.

O Workflow do GitHub Actions: Antes e Depois

Antes: Rodar Tudo Sempre

# .github/workflows/ci.yml — BEFORE
name: CI
on: [push, pull_request]

jobs:
  backend-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    runs-on: ubuntu-latest
    needs: [backend-test, frontend-test]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Todo job roda em todo PR. Uma correção de typo num arquivo de teste E2E dispara linting do backend, testes do backend, testes do frontend e testes E2E. Tempo total: 8 minutos e 18 segundos.

Depois: Rodar Só o Que É Relevante

# .github/workflows/ci.yml — AFTER
name: CI
on: [push, pull_request]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      run-backend: ${{ steps.changes.outputs.run-backend }}
      run-frontend: ${{ steps.changes.outputs.run-frontend }}
      run-e2e: ${{ steps.changes.outputs.run-e2e }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for accurate git diff
      - id: changes
        run: bash .github/scripts/detect-changes.sh

  backend-lint:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-e2e == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

A única adição é o job detect-changes (roda em ~5 segundos) e os condicionais if: em cada job downstream. Todo o resto é idêntico.

Os Resultados

Tipo de PRAntesDepoisEconomia
Mudanças apenas em E2E8 min 18 seg39 seg95%
Mudanças apenas no frontend6 min 13 seg1 min 32 seg75%
Mudanças apenas no backend5 min 45 seg2 min 10 seg62%
Mudanças full-stack8 min 18 seg8 min 18 seg0% (roda tudo)
Mudanças em CI/infra8 min 18 seg8 min 18 seg0% (rede de segurança)

Em uma semana típica — aproximadamente 40% PRs apenas de E2E, 30% apenas de frontend, 20% apenas de backend, 10% full-stack — isso projeta 7-8 horas por mês de tempo de espera economizado para os desenvolvedores.

A rede de segurança é importante: qualquer mudança em workflows do .github/, configurações Docker ou no Makefile dispara o pipeline completo. Você nunca pula a validação da infraestrutura que roda a sua validação.

Por Que Não Usar uma GitHub Action Para Isso?

É aqui que a história fica interessante. Eu auditei três GitHub Actions populares para filtragem de CI por caminho antes de escrever o script bash. O que encontrei me convenceu de que zero dependências era a decisão certa.

Action 1: Faz Chamadas Para uma API Comercial

Uma action popular de filtragem por caminho faz uma chamada de API para um serviço comercial em cada execução de CI. Leia de novo: seu pipeline de CI se comunica com um terceiro toda vez que roda. A action funciona corretamente quando o serviço está no ar e sua assinatura está ativa. Quando não está — e eu encontrei Issues no GitHub de usuários relatando isso — a action pode fazer seu build falhar com erro fatal. Seu pipeline de CI agora tem uma dependência de disponibilidade de um produto SaaS que você não controla.

Action 2: Alvo de Ataque à Cadeia de Suprimentos (CVE-2025-30066)

Em março de 2025, a amplamente utilizada GitHub Action tj-actions/changed-files — usada por mais de 23.000 repositórios — foi comprometida em um ataque à cadeia de suprimentos. O atacante modificou tags de versão existentes para apontar para código malicioso que fazia dump da memória do runner de CI, expondo secrets do workflow incluindo API keys, tokens e credenciais. A CISA emitiu um advisory formal (CVE-2025-30066). O ataque foi rastreado até um personal access token comprometido pertencente a uma conta bot com acesso de escrita ao repositório.

Isso não é um risco teórico. Isso aconteceu. Com uma action que faz exatamente o que meu script bash de 15 linhas faz.

Action 3: Funciona Bem, Mas é Desnecessária

A terceira action que avaliei é bem mantida, open-source e não tem problemas de segurança conhecidos. Funciona corretamente. Mas também adiciona uma dependência que precisa ter a versão fixada, ser monitorada para atualizações e auditada periodicamente. Para 15 linhas de bash que fazem a mesma coisa, a dependência não se justifica.

O Argumento de Segurança Para Zero Dependências

Toda GitHub Action que você adiciona ao seu workflow é código que você executa com acesso aos secrets do seu repositório, ao seu código-fonte e ao seu ambiente de CI. Cada action é uma decisão de confiança. O incidente do tj-actions/changed-files demonstrou que até actions populares e amplamente utilizadas podem ser comprometidas.

A abordagem com script bash tem um perfil de segurança diferente:

  • Zero chamadas de rede. O script lê a saída do git diff. Ele não busca nada, não se comunica com nada e não depende de nada fora do repositório.
  • Zero execução de código de terceiros. O script está versionado no seu repositório e revisado no mesmo processo de PR que o código da aplicação.
  • Zero jogos de fixação de versão. Não existe tag @v4 para se preocupar. Nenhum vetor de ataque à cadeia de suprimentos. O código é seu.
  • Auditabilidade total. Qualquer pessoa do time consegue ler 15 linhas de bash e entender exatamente o que ele faz.

Às vezes, a melhor dependência é nenhuma dependência.

Casos Especiais e Redes de Segurança

Algumas coisas que eu trato e que uma implementação ingênua deixaria passar:

Arquivos novos sem uma categoria clara

# If a file doesn't match any known pattern, run everything.
# Better to over-run than under-run.
*)
  RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;

O caso padrão dispara uma execução completa. Um tipo de arquivo não reconhecido nunca deve silenciosamente pular a validação.

Arquivos deletados

git diff --name-only inclui arquivos deletados. Um arquivo de backend deletado ainda precisa que a suíte de testes do backend rode — a deleção pode quebrar um import.

Merge commits

O fetch-depth: 0 no step de checkout é crítico. Sem o histórico completo do git, o git diff contra a branch base produz resultados imprecisos. Já vi configurações de CI com fetch-depth: 1 (o padrão) que perdem mudanças porque o clone raso não contém a base do merge.

O Que Você Pode Fazer Segunda-Feira de Manhã

  1. Meça sua baseline atual. Qual é a duração média do CI para o tipo de PR mais comum? Se você não sabe, confira as últimas 20 execuções do workflow.

  2. Categorize seus jobs de CI por diretório. Quais jobs correspondem a quais partes do codebase? A maioria dos pipelines tem um mapeamento natural.

  3. Escreva o script bash. Comece com a versão acima e adapte os padrões de diretório para a estrutura do seu repositório.

  4. Adicione o job detect-changes. Conecte-o ao seu workflow com condicionais if:.

  5. Adicione a rede de segurança. Qualquer mudança na configuração do CI, arquivos Docker ou scripts de infraestrutura deve disparar uma execução completa. Sempre.

  6. Meça o resultado. Compare os mesmos tipos de PR antes e depois. Os números vão fazer o argumento melhor do que qualquer discussão.

A Lição Mais Ampla

O pipeline de CI é a infraestrutura mais negligenciada na maioria das organizações de engenharia. Times vão passar semanas otimizando queries de banco de dados que economizam 200ms por requisição, e depois assistem cada desenvolvedor esperar 8 minutos de validação irrelevante de CI 5 vezes por dia sem questionar.

A solução aqui não é inteligente. Não é inovadora. São 15 linhas de bash que fazem uma pergunta simples: as mudanças neste PR realmente afetam o código que este job valida?

Se a resposta é não, pule. Isso não é cortar caminho. Isso é respeitar o tempo do seu time.

15 linhas. Zero dependências. 95% mais rápido. Às vezes a solução mais simples é aquela que ninguém tenta porque parece simples demais para funcionar.