Cache de dependências no CI: reutilizando node_modules, vendor, etc.

1. Por que o cache de dependências é crítico em pipelines CI?

Em ambientes de Integração Contínua (CI), cada execução de pipeline normalmente começa com um ambiente limpo. Isso significa que todas as dependências precisam ser baixadas e instaladas do zero — um processo que pode consumir de 2 a 10 minutos em projetos médios e até 30 minutos em monorepos complexos. O problema se agrava quando multiplicamos esse tempo por dezenas de execuções diárias, resultando em custos elevados de computação e desperdício de banda de rede.

É fundamental distinguir entre dois tipos de cache:
- Cache de dependências: armazena node_modules, vendor, .bundle — pastas gerenciadas por npm, Composer, Bundler, etc.
- Cache de build: armazena artefatos compilados, como bundles webpack ou binários compilados.

O cache de dependências foca em reutilizar bibliotecas que raramente mudam entre execuções. Enquanto isso, o cache de build lida com saídas geradas a partir do código-fonte — algo que Git versiona indiretamente via hashes de commit.

Cenários típicos incluem:

# Node.js (npm)
npm ci --cache .npm --prefer-offline

# PHP (Composer)
composer install --no-dev --prefer-dist --no-interaction

# Ruby (Bundler)
bundle install --path vendor/bundle --jobs 4 --retry 3

Cada comando acima pode ser otimizado com cache, reduzindo a instalação de 5 minutos para 10 segundos quando o cache é válido.

2. Estratégias de chaveamento de cache (cache keys) baseadas em Git

A escolha da chave de cache determina quando o cache será invalidado. Usar informações do Git permite um controle granular sobre essa decisão.

Usando git rev-parse HEAD~1 para cache incremental

# Chave baseada no commit anterior + hash do lockfile
CACHE_KEY=$(echo "$(git rev-parse HEAD~1):$(git hash-object package-lock.json)" | md5sum | cut -d' ' -f1)

Essa estratégia permite que o cache seja reutilizado mesmo após commits que não alteram dependências, desde que o hash do lockfile permaneça o mesmo. O acréscimo do commit anterior evita conflitos entre branches diferentes.

Combinando hash de package-lock.json com git log

# Chave que considera apenas mudanças no lockfile nos últimos 5 commits
LOCK_HASH=$(git log --oneline -5 -- package-lock.json | md5sum | cut -d' ' -f1)
CACHE_KEY="deps-${LOCK_HASH}"

Essa abordagem é mais conservadora: qualquer alteração no histórico recente do lockfile invalida o cache. É útil quando você quer garantir que dependências estejam sempre sincronizadas com alterações de configuração.

Chave com lockfile hash + branch name

# Evita contaminação entre branches
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")
CACHE_KEY="${BRANCH_NAME}-${LOCK_HASH}"

Essa é a estratégia mais segura para repositórios com múltiplos branches ativos. Cada branch mantém seu próprio cache, evitando que dependências de uma feature branch poluam o cache da main branch.

3. Implementação de cache em pipelines CI com Git-aware fallback

Estrutura de pastas e versionamento

# Estrutura típica de cache
~/.cache/npm/
project/node_modules/
project/vendor/
project/.bundle/

Para versionar essas pastas no cache, utilizamos comandos Git específicos:

# Restaurar cache parcial usando git checkout-index
git checkout-index --prefix=/tmp/cache/ -a
cp -r /tmp/cache/node_modules ./node_modules

Script de fallback completo

#!/bin/bash
# restore_cache.sh

CACHE_FILE="/tmp/deps-cache.tar.gz"
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")
CACHE_KEY="${CI_COMMIT_BRANCH}-${LOCK_HASH}"

# Tentar restaurar do cache
if [ -f "${CACHE_FILE}" ]; then
    echo "Restaurando cache existente..."
    tar -xzf "${CACHE_FILE}" -C .
    # Verificar integridade com git hash-object
    if [ "$(git hash-object node_modules/.cache-key 2>/dev/null)" = "$CACHE_KEY" ]; then
        echo "Cache válido. Pulando instalação."
        exit 0
    fi
fi

# Fallback: instalar dependências e gerar novo cache
echo "Cache expirado ou ausente. Instalando dependências..."
npm ci --prefer-offline --no-audit --no-fund
composer install --no-dev --prefer-dist --no-interaction

# Gerar novo tarball com git archive (apenas arquivos versionados)
echo "Gerando novo cache..."
echo "$CACHE_KEY" > node_modules/.cache-key
tar -czf "${CACHE_FILE}" node_modules vendor .bundle

4. Gerenciando dependências de múltiplos projetos no mesmo repositório (monorepo)

Cache seletivo por workspace

# Detectar mudanças em workspaces específicos
CHANGED_PACKAGES=$(git diff --name-only HEAD~1 HEAD -- packages/ | cut -d'/' -f1-2 | sort -u)

for pkg in $CHANGED_PACKAGES; do
    if [ -f "${pkg}/package-lock.json" ]; then
        echo "Cache invalidado para: ${pkg}"
        # Atualizar cache apenas desse workspace
        (cd "${pkg}" && npm ci --cache .npm --prefer-offline)
    fi
done

Compartilhando cache com git submodule ou git subtree

# Invalidar cache de um submódulo específico
SUB_CHANGES=$(git submodule status | grep '^+' | awk '{print $2}')
for sub in $SUB_CHANGES; do
    (cd "submodules/${sub}" && git log --oneline -1 -- package-lock.json)
    # Atualizar cache do submódulo
    (cd "submodules/${sub}" && npm ci --prefer-offline)
done

Invalidar apenas o vendor de um módulo específico

# Usando git log para detectar mudanças em composer.json de um módulo
MODULE="packages/api"
if git log --oneline HEAD~5..HEAD -- "${MODULE}/composer.json" | grep -q .; then
    echo "Mudanças detectadas em ${MODULE}. Invalidando cache..."
    (cd "${MODULE}" && composer install --no-dev --prefer-dist)
fi

5. Git hooks e cache: evitando instalações desnecessárias

Pre-commit hook para verificar mudanças no lockfile

#!/bin/bash
# .git/hooks/pre-commit

# Verificar se package-lock.json foi alterado no staged
if git diff --cached --name-only --diff-filter=M | grep -q "package-lock.json"; then
    echo "AVISO: package-lock.json foi modificado. Execute 'npm ci' localmente."
    echo "Para forçar o commit, use --no-verify"
    exit 1
fi

Post-commit hook para atualizar cache local

#!/bin/bash
# .git/hooks/post-commit

# Atualizar cache baseado no último commit
LAST_COMMIT=$(git rev-parse HEAD)
LOCK_HASH=$(git hash-object package-lock.json 2>/dev/null || echo "no-lock")

# Se o lockfile mudou, reinstalar dependências
if git diff --name-only HEAD~1..HEAD | grep -q "package-lock.json"; then
    echo "Lockfile alterado. Reinstalando dependências..."
    npm ci --prefer-offline
fi

Integração com git bisect para testar versões antigas

# Script para git bisect que reutiliza cache
#!/bin/bash
# .git/bisect/run

# Restaurar cache do commit atual
git checkout-index --prefix=/tmp/cache/ -a
cp -r /tmp/cache/node_modules ./node_modules 2>/dev/null || npm ci

# Executar testes
npm test

6. Boas práticas de segurança e consistência no cache

Checksum com git hash-object

# Gerar checksum do lockfile para validar cache
LOCK_CHECKSUM=$(git hash-object package-lock.json)
CACHE_CHECKSUM=$(cat node_modules/.checksum 2>/dev/null)

if [ "$LOCK_CHECKSUM" != "$CACHE_CHECKSUM" ]; then
    echo "Cache corrompido ou desatualizado. Reinstalando..."
    rm -rf node_modules
    npm ci
    echo "$LOCK_CHECKSUM" > node_modules/.checksum
fi

Limpeza automática de caches de branches deletadas

#!/bin/bash
# cleanup_ci_cache.sh

# Listar branches que já foram mergeadas e deletadas
MERGED_BRANCHES=$(git branch --merged origin/main | grep -v "main" | grep -v "\*")

for branch in $MERGED_BRANCHES; do
    echo "Limpando cache da branch: ${branch}"
    # Comando específico do seu CI para remover cache
    # curl -X DELETE "https://api.ci.com/cache/${branch}"
done

Estratégia de cache por runner com validação Git

# Validar estado do repositório antes de usar cache
if git rev-parse --verify HEAD > /dev/null 2>&1; then
    echo "Repositório em estado válido. Usando cache..."
    # Restaurar cache
else
    echo "Repositório inconsistente. Instalação limpa..."
    git clean -fdx
    npm ci
fi

7. Métricas e otimização: medindo o impacto do cache

Comparação de tempo com e sem cache

#!/bin/bash
# measure_cache_impact.sh

# Pipeline sem cache
echo "Executando pipeline sem cache..."
START=$(date +%s)
npm ci
END=$(date +%s)
echo "Sem cache: $((END - START)) segundos"

# Pipeline com cache
echo "Executando pipeline com cache..."
START=$(date +%s)
npm ci --prefer-offline --cache .npm
END=$(date +%s)
echo "Com cache: $((END - START)) segundos"

# Registrar estatísticas no Git
echo "$(git log --oneline -1): $(date +%Y-%m-%d): $((END - START))s" >> cache_stats.txt

Monitoramento de hits/misses

# Script para registrar hits e misses do cache
CACHE_HIT=false
if [ -f node_modules/.cache-key ]; then
    PREV_KEY=$(cat node_modules/.cache-key)
    CURRENT_KEY=$(git rev-parse HEAD)
    if [ "$PREV_KEY" = "$CURRENT_KEY" ]; then
        CACHE_HIT=true
    fi
fi

# Registrar no log do Git
if $CACHE_HIT; then
    echo "CACHE HIT: $(git show --no-patch --format='%H %s' HEAD)" >> cache_audit.log
else
    echo "CACHE MISS: $(git show --no-patch --format='%H %s' HEAD)" >> cache_audit.log
fi

Quando desabilitar o cache

# Cenários de segurança: forçar instalação limpa
if [ "${CI_SECURITY_SCAN}" = "true" ]; then
    echo "Scan de segurança ativo. Ignorando cache..."
    git clean -fdx
    npm ci --no-audit --no-fund
else
    # Usar cache normalmente
    ./restore_cache.sh
fi

Referências