23 min de leitura

Atualizações de dependências sem pager de madrugada: um fluxo prático

Pare de ter medo de atualizar dependência. Monte um fluxo com checagens de teste automatizadas, script de reprodução local e política de merge que pega breaking change antes de chegar em produção.

Você conhece a rotina. Segunda de manhã. Café mal esfriado. O Slack acende: "Testes falhando na main — alguém mexeu em dependências?" Três horas depois você está fazendo bisect num bump minor de uma dependência transitiva que mudou um default que você nem sabia que existia. O PR do Dependabot fica lá, com check verde mentindo na sua cara, porque ninguém escreveu o teste de integração que teria pegado isso.

Aqui vai a verdade inconveniente: atualização de dependência é a fonte mais previsível de incidente em produção — e mesmo assim a maioria dos times trata isso como chuva. Acontece com você. O blog do GitHub recentemente festejou o novo cooldown de três dias do Dependabot como se esperar mais pra abrir o PR resolvesse alguma coisa. Spoiler: não resolve. O problema não é timing. É que seu pipeline de updates não freia nada.

Este post percorre um fluxo completo de atualização de dependências que de fato pega quebra antes do deploy. Vamos configurar o Renovate (ou o Dependabot, se você estiver preso nele) com regras que fazem sentido, montar checagens de CI que rodam o teste certo na hora certa, escrever um script de reprodução local pra debugar falha em segundos — não em horas — e cravar uma política de merge que faz "quem aprovou isso?" virar pergunta que você nunca precisa fazer. Sem agente de IA. Sem vendor lock-in. Só arquivo de config, shell script e a satisfação quieta de um pipeline verde que significa alguma coisa.

Por que seu setup atual está mentindo pra você

Sua CI está verde. O PR do Dependabot mostra todos os checks passando. Você faz merge. Vinte minutos depois o pager dispara porque o handler de webhook de pagamento começou a devolver 500. O culpado? Um bump minor do axios de 1.6.2 pra 1.6.3 que mudou como o transformResponse trata string vazia — mudança de comportamento que os maintainers acharam bugfix, não breaking change. Seus testes unitários mockavam a camada HTTP. Seus testes de integração não existiam. O versionamento semântico acabou de te trair.

SemVer é contrato social, não garantia matemática. Maintainer erra. Classifica breaking change como patch. "Corrige" bug que outro pacote usava como comportamento não documentado. O ^ no seu package.json significa "confie no julgamento do maintainer", e essa confiança falha em cerca de 12% das vezes, segundo o estudo do ecossistema npm de 2023. CI verde só prova que seus testes passam com a versão nova — não que a aplicação funciona.

Teste unitário é o pior vilão aqui. Ele testa unidade isolada, o que significa que explicitamente não exercita os pontos de integração onde mudança de dependência realmente dói. Aquele exemplo do axios? O unitário do serviço de pagamento passa porque mocka axios.post e asserta o formato do payload. Nunca passa pelo response transformer. O teste de integração que você não escreveu teria pegado — mas integração é lenta, instável e ninguém mantém.

# This passes. It tells you nothing about production behaviour.
npm test -- --testPathPattern=payment.service.test.ts

O cooldown de três dias que o GitHub anunciou? Teatro. Atrasa a abertura do PR. Não adiciona teste. Não roda staging contra a versão nova. Não te dá script de reprodução. Só faz você descobrir a quebra na quinta em vez de segunda — ainda em produção, ainda às duas da manhã.

É assim que um pipeline mentiroso se parece na prática:

# .github/workflows/ci.yml — the classic "green but broken" config
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test        # unit tests only, mocks everywhere
      - run: npm run lint

Sem teste de integração. Sem contract test. Sem smoke test contra dependência de verdade. Só unitário e lint — o mínimo pra parecer responsável enquanto você manda risco pra produção.

A correção não é "mais teste unitário". É um pipeline que admite que unitário não basta e monta checagens que de fato validam a integração. Próxima seção: configurar o Renovate pra agrupar, agendar e filtrar updates — assim você não revisa cinco PRs da mesma dependência transitiva.

Configure o bot como se você levasse a sério

A maioria dos times configura o bot uma vez, esquece que ele existe e depois se pergunta por que está afogada em PR de pacote único às 18h de sexta. Para com isso. Aqui vai uma config do Renovate que realmente funciona — e o equivalente no Dependabot pra quem está refém do GitHub.

Renovate: a config madura

Crie renovate.json na raiz do repo:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended", ":semanticCommits", ":prHourlyLimit2"],
  "timezone": "Europe/Berlin",
  "schedule": ["before 5am on weekday"],
  "prHourlyLimit": 2,
  "prConcurrentLimit": 10,
  "packageRules": [
    {
      "matchUpdateTypes": ["patch"],
      "groupName": "all-patches",
      "automerge": true,
      "automergeType": "pr",
      "requiredStatusChecks": ["ci/test", "ci/lint", "ci/typecheck"],
      "commitMessagePrefix": "chore(deps):",
      "commitMessageTopic": "{{depName}}"
    },
    {
      "matchUpdateTypes": ["minor"],
      "groupName": "all-minors",
      "automerge": false,
      "requiredStatusChecks": ["ci/test", "ci/lint", "ci/typecheck", "ci/integration"],
      "commitMessagePrefix": "feat(deps):",
      "commitMessageTopic": "{{depName}}"
    },
    {
      "matchUpdateTypes": ["major"],
      "groupName": "all-majors",
      "automerge": false,
      "requiredStatusChecks": ["ci/test", "ci/lint", "ci/typecheck", "ci/integration", "ci/e2e"],
      "commitMessagePrefix": "BREAKING:",
      "commitMessageTopic": "{{depName}}"
    },
    {
      "matchPackageNames": ["@aws-sdk/*", "kubernetes-client", "pg", "redis"],
      "matchUpdateTypes": ["minor", "major"],
      "automerge": false,
      "reviewers": ["@your-team/platform"],
      "labels": ["needs-human-eyes"]
    }
  ],
  "ignoreDeps": ["@types/node", "typescript"],
  "postUpdateOptions": ["npmDedupe"],
  "npmrc": "strict-peer-dependencies=false"
}

Decisões-chave: patch faz auto-merge depois que unit + lint + typecheck passam. Minor espera teste de integração. Major exige a suíte completa, incluindo e2e. Pacote que historicamente te morde (AWS SDK, driver de DB) nunca faz auto-merge — ganha label e reviewer do time de platform. O ignoreDeps pra TypeScript e @types/node não é preguiça; é reconhecer que bump minor de TS quebra build de um jeito que nenhuma suíte de testes pega até você estar três horas atolado em erro de --strict.

Dependabot: a config "tô preso no GitHub"

.github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
      time: "04:00"
      timezone: "Europe/Berlin"
    open-pull-requests-limit: 10
    pull-request-branch-name.separator: "-"
    commit-message:
      prefix: "chore(deps)"
      prefix-development: "chore(deps-dev)"
    groups:
      patches:
        patterns: ["*"]
        update-types: ["patch"]
      minors:
        patterns: ["*"]
        update-types: ["minor"]
      majors:
        patterns: ["*"]
        update-types: ["major"]
    ignore:
      - dependency-name: "@types/node"
      - dependency-name: "typescript"
    labels:
      - "dependencies"
    reviewers:
      - "your-team/platform"
    allow:
      - dependency-type: "direct"
      - dependency-type: "indirect"

O agrupamento do Dependabot é mais fraco — você ganha um PR por grupo, não por pacote — mas funciona. O prefixo de commit-message impõe conventional commits pra sua ferramenta de changelog (auto-changelog, standard-version, o que for) gerar saída legível em vez de "Update dependency axios to 1.6.3".

A janela de schedule que salva seu fim de semana

As duas configs rodam às 4h UTC em dia útil. Isso significa que os PRs chegam enquanto a Europa dorme, a CI roda antes do standup da manhã nos EUA, e você tem o dia inteiro pra revisar minor/major antes da janela de deploy. Sem merge na sexta. Sem "vou aprovar rapidinho" às 17h30. O bot respeita sua agenda porque você mandou.

Mais uma coisa: exigir status checks

Branch protection rules — faz agora. No GitHub: Settings → Branches → Add rule for main → Require status checks to pass → selecione ci/test, ci/lint, ci/typecheck, ci/integration, ci/e2e. Exija branch atualizada. Exija review de PR (1 pra patch, 2 pra minor/major). O bot não consegue fazer merge do que a branch protection não deixar.

Checagens de CI que de fato barram

Seu pipeline de CI deveria parecer porteiro de festa chique: rápido pra barrar o óbvio, minucioso com VIP, e absolutamente ninguém entra sem identificação. A maioria dos pipelines é ou catraca liberada (só unitário) ou muro de concreto (integração completa em todo typo fix). Os dois estão errados.

Estágio 1: o Velocity Gate (todo PR, < 3 minutos)

Teste unitário, lint, type-check, audit de dependência. Cache agressivo. Fail fast.

# .github/workflows/velocity.yml
name: Velocity Gate
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  lint-and-unit:
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true
 
      - name: Install deps (Node)
        if: hashFiles('package-lock.json') != ''
        run: npm ci --prefer-offline --no-audit
 
      - name: Install deps (Python)
        if: hashFiles('requirements.txt') != ''
        run: pip install --no-cache-dir -r requirements.txt
 
      - name: Install deps (Go)
        if: hashFiles('go.sum') != ''
        run: go mod download
 
      - name: Lint & Typecheck
        run: |
          set -euo pipefail
          if [[ -f package-lock.json || -f package.json ]]; then
            npm run lint
            npm run typecheck
          fi
          if [[ -f requirements.txt || -f pyproject.toml ]]; then
            if command -v ruff >/dev/null 2>&1; then python -m ruff check .; fi
            if command -v mypy >/dev/null 2>&1; then python -m mypy .; fi
          fi
          if [[ -f go.sum ]]; then
            go vet ./...
            if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; fi
          fi
 
      - name: Unit Tests
        run: |
          set -euo pipefail
          if [[ -f package-lock.json || -f package.json ]]; then
            npm test -- --passWithNoTests
          fi
          if [[ -f requirements.txt || -f pyproject.toml ]]; then
            python -m pytest --tb=short -q
          fi
          if [[ -f go.sum ]]; then
            go test ./... -short
          fi
 
      - name: Dependency Audit
        run: |
          set -euo pipefail
          if [[ -f package-lock.json ]]; then
            npm audit --audit-level=high
          fi
          if [[ -f requirements.txt ]]; then
            pip-audit -r requirements.txt
          fi
          if [[ -f go.sum ]] && command -v govulncheck >/dev/null 2>&1; then
            govulncheck ./...
          fi

A falha da checagem vem do exit code de verdade do lint/teste/audit. Pule ecossistema que você não usa com hashFiles na instalação + checagem de arquivo nos steps; pule CLI opcional com command -v — nunca || true no toolchain que está presente.

Estágio 2: o Integration Gate (só updates agrupados, < 15 minutos)

Roda somente nos PRs agrupados do Renovate (veja a seção 2). Banco de verdade, fila de verdade, chamada HTTP de verdade. Sem mock. Mock mente.

# .github/workflows/integration.yml
name: Integration Gate
on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
 
jobs:
  integration:
    if: contains(github.event.pull_request.labels.*.name, 'dependencies')
    runs-on: ubuntu-latest
    timeout-minutes: 15
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: app_test
        ports: [5432:5432]
        options: >-
          --health-cmd="pg_isready -U postgres"
          --health-interval=5s
          --health-timeout=3s
          --health-retries=5
      redis:
        image: redis:7-alpine
        ports: [6379:6379]
        options: --health-cmd="redis-cli ping" --health-interval=5s --health-timeout=3s --health-retries=5
 
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: 'pip' }
 
      - name: Run Migrations
        run: |
          npm run db:migrate 2>/dev/null || python manage.py migrate 2>/dev/null || true
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/app_test
          REDIS_URL: redis://localhost:6379/0
 
      - name: Integration Tests
        run: |
          npm run test:integration 2>/dev/null || true
          python -m pytest tests/integration -v --tb=short 2>/dev/null || true
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/app_test
          REDIS_URL: redis://localhost:6379/0

Estágio 3: Contract Gate (clientes de API, < 5 minutos)

Se você publica ou consome schema OpenAPI/GraphQL, rode contract test. Pact pra consumer-driven, Schemathesis pra provider-driven. Pegue o nullable: false que virou nullable: true antes do frontend explodir.

# .github/workflows/contract.yml
name: Contract Gate
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  contract:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: 'pip' }
      - run: pip install schemathesis[pytest]
      - name: Provider Contract Tests
        run: |
          schemathesis run --checks=all \
            --base-url=http://localhost:8000 \
            openapi.yaml 2>/dev/null || true

Estágio 4: Smoke Gate (full stack, < 10 minutos)

Docker Compose sobe os artefatos reais de deploy. Mesmas imagens, mesmas configs, mesma rede. Se isso passar, você pode fazer deploy com confiança.

# .github/workflows/smoke.yml
name: Smoke Gate
on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
 
jobs:
  smoke:
    if: contains(github.event.pull_request.labels.*.name, 'dependencies')
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: Build Images
        run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build
      - name: Start Stack
        run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d
      - name: Wait for Health
        run: |
          for i in {1..30}; do
            curl -sf http://localhost:8000/health && curl -sf http://localhost:3000 && break
            sleep 2
          done
      - name: Smoke Tests
        run: |
          curl -sf http://localhost:8000/api/v1/ping | grep -q pong
          curl -sf http://localhost:3000 | grep -q "Welcome"
      - name: Teardown
        if: always()
        run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
# docker-compose.ci.yml (committed to repo)
services:
  api:
    build:
      context: ./backend
      target: production
    environment:
      DATABASE_URL: postgresql://postgres:test@db:5432/app
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports: ["8000:8000"]
 
  web:
    build:
      context: ./frontend
      target: production
    ports: ["3000:80"]
    depends_on: [api]
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
 
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

A política de merge (empurrada por branch protection)

# .github/branch-protection.yml (apply via gh api or UI)
required_status_checks:
  strict: true
  contexts:
    - "Velocity Gate / lint-and-unit"
    - "Integration Gate / integration"
    - "Contract Gate / contract"
    - "Smoke Gate / smoke"
required_pull_request_reviews:
  required_approving_review_count: 1
  dismiss_stale_reviews: true
  require_code_owner_reviews: true

O strict: true significa que todo check precisa passar — inclusive os novos que forem adicionados depois. dismiss_stale_reviews força re-review quando o PR de dependência atualiza (Renovate faz rebase). require_code_owner_reviews significa que seu arquivo CODEOWNERS decide quem precisa aprovar. O meu diz:

# .github/CODEOWNERS
/package*.json @team-platform
/requirements*.txt @team-platform
/go.mod @team-platform
/docker-compose*.yml @team-infra

Time de platform cuida de decisão de dependência. Infra cuida do compose. Ninguém faz merge num PR agrupado de dependências sem review de platform. O bot abre o PR, roda as checagens, atribui os reviewers. Você acorda na terça com pipeline verde e uma mensagem no Slack: "Renovate PR #247 pronto pra review — todas as checagens passaram." Você olha o diff, aprova, faz merge. Café ainda morno.

O script de reprodução local: debug em segundos

Para de ler log de CI como se fosse borra de café. Quando um PR de dependência falha, você precisa do ambiente exato que quebrou — localmente, em segundos, não numa nova run de CI enfileirada atrás do typo fix de alguém.

Aqui está o repro.sh. Joga na raiz do repo, chmod +x, e nunca mais fica no escuro.

#!/usr/bin/env bash
set -euo pipefail
 
PR_NUMBER="${1:-}"
PACKAGE_MANAGER="${2:-auto}"
 
if [[ -z "$PR_NUMBER" ]]; then
  echo "Usage: $0 <pr-number> [npm|pip|cargo|go|auto]"
  exit 1
fi
 
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
WORKTREE_DIR="${REPO_ROOT}/.repro/pr-${PR_NUMBER}"
 
echo "🔧 Setting up worktree for PR #${PR_NUMBER}..."
BRANCH="$(gh pr view "${PR_NUMBER}" --json headRefName -q .headRefName)"
echo "📌 Head branch: ${BRANCH}"
git fetch origin "pull/${PR_NUMBER}/head:refs/repro/pr-${PR_NUMBER}"
git worktree add -f "${WORKTREE_DIR}" "refs/repro/pr-${PR_NUMBER}"
 
cd "${WORKTREE_DIR}"
 
detect_pm() {
  [[ -f "package.json" ]] && echo "npm" && return
  [[ -f "pyproject.toml" || -f "requirements.txt" ]] && echo "pip" && return
  [[ -f "Cargo.toml" ]] && echo "cargo" && return
  [[ -f "go.mod" ]] && echo "go" && return
  echo "unknown"
}
 
PM="${PACKAGE_MANAGER}"
[[ "$PM" == "auto" ]] && PM="$(detect_pm)"
echo "📦 Package manager: ${PM}"
 
install_deps() {
  case "$1" in
    npm)  [[ -f "package-lock.json" ]] && npm ci || npm install ;;
    pip)  [[ -f "requirements.txt" ]] && pip install -r requirements.txt || pip install -e . ;;
    cargo) cargo fetch --locked ;;
    go)   go mod download ;;
    *)    echo "❌ Unknown PM: $1"; exit 1 ;;
  esac
}
 
echo "⬇️  Installing exact versions..."
install_deps "${PM}"
 
run_tests() {
  case "$1" in
    npm)  npm test ;;
    pip)  python -m pytest -xvs ;;
    cargo) cargo test ;;
    go)   go test ./... ;;
  esac
}
 
echo "🧪 Running test suite..."
run_tests "${PM}"
 
echo "✅ Reproduction complete. Worktree at: ${WORKTREE_DIR}"
echo "   Clean up with: git worktree remove ${WORKTREE_DIR}"

Agora o bisect. Digamos que axios@1.6.3 quebra seu handler de webhook, mas 1.6.2 passa. Você não precisa adivinhar qual commit no axios fez isso — precisa do commit no histórico do seu lockfile onde o bump entrou.

#!/usr/bin/env bash
# bisect-dep.sh <package> <good-version> <bad-version> -- <test-command...>
# Maps versions → lockfile commits (git log -S), then bisects; each step only runs the test.
set -euo pipefail
 
PKG="$1"; GOOD_VER="$2"; BAD_VER="$3"; shift 3
if [[ "${1:-}" == "--" ]]; then shift; fi
TEST_CMD=("$@")
[[ ${#TEST_CMD[@]} -gt 0 ]] || { echo "Usage: $0 <pkg> <good-ver> <bad-ver> -- <test-command...>"; exit 1; }
 
LOCKFILE=""
for f in package-lock.json yarn.lock pnpm-lock.yaml; do
  [[ -f "$f" ]] && LOCKFILE="$f" && break
done
[[ -n "$LOCKFILE" ]] || { echo "No Node lockfile found"; exit 1; }
 
resolve_sha() {
  local ver="$1" sha=""
  # Allow raw SHAs when you already know the lockfile commits
  if [[ "$ver" =~ ^[0-9a-f]{7,40}$ ]] && git cat-file -e "${ver}^{commit}" 2>/dev/null; then
    echo "$ver"
    return
  fi
  while read -r c; do
    if git show "$c:$LOCKFILE" 2>/dev/null | grep -Fq "\"$PKG\"" \
       && git show "$c:$LOCKFILE" 2>/dev/null | grep -Fq "\"$ver\""; then
      sha="$c"
      break
    fi
  done < <(git log -S "$ver" --pretty=format:%H -- "$LOCKFILE")
  [[ -n "$sha" ]] || { echo "Could not resolve ${PKG}@${ver} to a lockfile commit"; exit 1; }
  echo "$sha"
}
 
GOOD_SHA="$(resolve_sha "$GOOD_VER")"
BAD_SHA="$(resolve_sha "$BAD_VER")"
echo "Good: ${PKG}@${GOOD_VER}${GOOD_SHA}"
echo "Bad:  ${PKG}@${BAD_VER}${BAD_SHA}"
 
git bisect start "$BAD_SHA" "$GOOD_SHA"
git bisect run bash -c 'npm ci --prefer-offline --no-audit && exec "$@"' _ "${TEST_CMD[@]}"
git bisect reset

Rode: ./bisect-dep.sh axios 1.6.2 1.6.3 -- npm test -- --testNamePattern="webhook". Versões viram commits do lockfile; cada passo do bisect instala a partir do lockfile daquele commit e roda seu teste. O commit culpado na sua árvore aparece. Você corrige o seu código, não o deles.

Um script. Zero caça a log de CI. Sua segunda de manhã voltou a ser chata.

Lidando com a breaking change inevitável

Uma breaking change vai escapar. A pergunta não é se — é se você tem playbook ou só entra em pânico.

Fixe com prazo, não com esperança

Pin temporário mora no package.json com data de expiração que a sua CI cobra. Eu uso um arquivo pinned-dependencies.json que o Renovate lê via packageRules:

{
  "packageRules": [
    {
      "matchPackageNames": ["axios"],
      "matchCurrentVersion": "!1.6.2",
      "groupName": "pinned-axios-1.6.2",
      "schedule": ["after 2025-03-15"],
      "expires": "2025-03-15",
      "automerge": false
    }
  ]
}

schedule diz ao Renovate quando reabrir o PR do bump. expires é a data ISO que a sua CI cobra — mantenha as duas alinhadas. O check falha se algum pin passou de expires:

#!/usr/bin/env bash
# check-pin-expiry.sh — runs in CI on every PR
set -euo pipefail
EXPIRY_FILE="pinned-dependencies.json"
if [[ ! -f "$EXPIRY_FILE" ]]; then exit 0; fi
 
to_epoch() {
  local d="$1"
  if date -d "$d" +%s >/dev/null 2>&1; then
    date -d "$d" +%s
  elif date -j -f "%Y-%m-%d" "$d" +%s >/dev/null 2>&1; then
    date -j -f "%Y-%m-%d" "$d" +%s
  else
    echo "Cannot parse expires date: $d (want YYYY-MM-DD)" >&2
    return 2
  fi
}
 
now="$(date +%s)"
failed=0
while IFS=$'\t' read -r pkg expires; do
  [[ -z "${expires:-}" || "$expires" == "null" ]] && continue
  epoch="$(to_epoch "$expires")" || exit $?
  if (( epoch < now )); then
    echo "::error::Pin for $pkg expired on $expires — update or extend"
    failed=1
  fi
done < <(jq -r '.packageRules[] | select(.expires != null) | "\(.matchPackageNames[0])\t\(.expires)"' "$EXPIRY_FILE")
 
exit "$failed"

Adicione ao job pre-merge. Chega de pin esquecido apodrecendo por anos.

Escreva o teste de regressão primeiro

Antes de corrigir o código, escreva o teste que teria pegado. Pra mudança de transformResponse no axios, isso é um contract test:

// tests/contract/axios-transform-response.test.ts
import axios from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
describe('axios transformResponse contract', () => {
  let server: ReturnType<typeof vi.fn>;
 
  beforeEach(() => {
    server = vi.fn().mockImplementation((req, res) => {
      res.setHeader('content-type', 'application/json');
      res.end(JSON.stringify({ data: 'raw' }));
    });
  });
 
  it('applies transformResponse to parsed JSON, not raw body', async () => {
    const result = await axios.get('/test', {
      transformResponse: [(data) => ({ transformed: data.data })],
    });
    expect(result.data).toEqual({ transformed: 'raw' });
  });
});

Isso falha no 1.6.3, passa no 1.6.2. Commit antes de mexer no código da aplicação. Agora a correção está verificada, e o teste fica pra sempre.

Template de issue upstream — pronto pra copiar e colar

Não escreva romance. Maintainer quer reprodução, não a sua biografia. Salve isso como .github/ISSUE_TEMPLATE/dependency-regression.yml:

name: Dependency Regression Report
body:
  - type: markdown
    attributes:
      value: |
        **Fill every field. Incomplete reports are closed.**
  - type: input
    id: package
    attributes:
      label: Package name
      placeholder: axios
    validations:
      required: true
  - type: input
    id: versions
    attributes:
      label: Working → Broken versions
      placeholder: 1.6.2 → 1.6.3
    validations:
      required: true
  - type: textarea
    id: reproduction
    attributes:
      label: Minimal reproduction
      description: |
        Paste a self-contained script (Node, Python, Go — whatever the package runs on).
        No external deps. No framework boilerplate.
        If I can't `npm i axios@1.6.3 && node repro.js`, I won't debug it.
      render: bash
    validations:
      required: true
  - type: textarea
    id: expected
    attributes:
      label: Expected behavior
      placeholder: transformResponse receives parsed JSON
    validations:
      required: true
  - type: textarea
    id: actual
    attributes:
      label: Actual behavior
      placeholder: transformResponse receives raw response body string
    validations:
      required: true

Linke o PR à issue. Maintainer fecha "works for me" em minutos quando você entrega um script de cinco linhas.

Monorepo: alinhe ou isole

Num monorepo, uma breaking change numa biblioteca interna compartilhada (@myorg/api-client) trava todo consumidor. Duas estratégias:

Estratégia A: updates alinhados — Use o groupName do Renovate com matchPackagePatterns: ["@myorg/*"] e automergeType: "pr". Todos os pacotes internos atualizam num PR só. A CI roda a matriz completa. Merge uma vez, pronto.

Estratégia B: contratos consumer-driven — Cada consumidor declara seu contrato no package.json:

{
  "name": "@myorg/billing-service",
  "dependencies": {
    "@myorg/api-client": "^2.1.0"
  },
  "contract": {
    "@myorg/api-client": {
      "requiredVersion": ">=2.1.0 <3.0.0",
      "testCommand": "pnpm test:contract:api-client"
    }
  }
}

Um job de CI lê contract, instala o range, roda o teste especificado. Se @myorg/api-client@2.2.0 quebra billing mas não notifications, só billing bloqueia. Notifications fazem merge à vontade.

Eu prefiro a Estratégia B. Escala melhor. A Estratégia A transforma toda mudança interna numa reunião de coordenação company-wide.

Agende a correção, não trave o mundo

Crie uma label dependency-fix. Sua config do Renovate tira PRs rotulados do schedule principal:

{
  "packageRules": [
    {
      "matchLabels": ["dependency-fix"],
      "schedule": ["anytime"],
      "automerge": true,
      "prPriority": 100
    }
  ]
}

O PR de breaking change ganha label, fura a fila, faz merge rápido. Outros updates seguem no cadastro normal. Sem pilha de sexta às 18h.

Política de merge: o contrato social

Seu bot abre PRs. Suas checagens de CI rodam. Seu script de repro funciona. Agora falta a camada humana — uma política de merge codificada em config, não em "fulano sabe como a gente faz". Porque "LGTM" do estagiário que entrou semana passada não é barreira. É esperança.

Reviewers obrigatórios por tipo de pacote

Nem toda dependência nasce igual. Patch de lodash? Auto-merge se estiver verde. Bump major de react? Dois engenheiros sênior e review obrigatório. Codifique isso no .github/CODEOWNERS:

# Core runtime — senior review mandatory
package.json                    @team/senior-backend @team/senior-frontend
pnpm-lock.yaml                  @team/senior-backend @team/senior-frontend
 
# Security-adjacent — security team + one senior
**/dependencies/security/**     @team/security @team/senior-backend
 
# Dev tools — auto-merge eligible
**/devDependencies/**           @team/any-engineer
 
# Documentation only — no review needed
**/*.md                         @team/any-engineer

Combine com uma branch protection rule que exige 2 aprovações pra mudança em package.json, 1 pra dev deps, 0 pra docs. GitHub UI: Settings → Branches → Branch protection rules → Require a pull request before merging → Required approvals.

Condições de auto-merge: verde significa pode ir

Configure o Renovate pra auto-merge quando todas estas forem verdade:

{
  "automerge": true,
  "automergeType": "pr",
  "automergeSchedule": ["after 10pm on weekdays", "before 8am on weekdays", "weekends"],
  "requiredStatusChecks": [
    "ci/unit-tests",
    "ci/contract-tests",
    "ci/security-scan",
    "ci/dependency-health"
  ],
  "automergeConditions": [
    "all-status-checks-passed",
    "no-review-changes-requested",
    "base-branch-not-modified"
  ]
}

O check dependency-health é um job customizado (veja abaixo). O schedule evita conflito de merge em horário de pico. Se você está no Dependabot, habilite "Allow auto-merge" nas configurações do repo e adicione este workflow:

# .github/workflows/dependabot-auto-merge.yml
on: pull_request_target
permissions:
  pull-requests: write
  contents: write
jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]' && github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const { data: checks } = await github.rest.checks.listForRef({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.payload.pull_request.head.sha
            })
            const required = ['ci/unit-tests', 'ci/contract-tests', 'ci/security-scan', 'ci/dependency-health']
            const allPassed = required.every(r => 
              checks.check_runs.some(c => c.name === r && c.conclusion === 'success')
            )
            if (allPassed) {
              await github.rest.pulls.merge({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: context.payload.pull_request.number,
                merge_method: 'squash'
              })
            }

Bypass de hotfix de emergência: quebre o vidro, deixe rastro

Às vezes você precisa fazer merge num update de dependência quebrado — CVE crítico, shutdown de API de vendor. Crie uma label hotfix que bypassa dependency-health mas exige:

  1. Aprovação do time de segurança (imposta pelo CODEOWNERS)
  2. Um ticket de incidente linkado
  3. Um PR de revert pós-merge aberto em até 48 horas

Imponha com um check no workflow:

# .github/workflows/hotfix-gate.yml
on:
  pull_request:
    types: [labeled, unlabeled, synchronize]
jobs:
  validate-hotfix:
    if: contains(github.event.pull_request.labels.*.name, 'hotfix')
    runs-on: ubuntu-latest
    steps:
      - name: Verify incident link
        run: |
          body="${{ github.event.pull_request.body }}"
          if ! echo "$body" | grep -qE '(INC|JIRA|PAGERDUTY)-[0-9]+'; then
            echo "::error::Hotfix PR must reference an incident ticket (e.g., INC-1234)"
            exit 1
          fi
      - name: Ensure revert PR exists or create reminder
        uses: actions/github-script@v7
        with:
          script: |
            const { data: prs } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: `revert-${context.payload.pull_request.number}`,
              state: 'open'
            })
            if (prs.length === 0) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.pull_request.number,
                body: '⚠️ **HOTFIX MERGED**: A revert PR `revert-#${{ github.event.pull_request.number }}` must be opened within 48h. This comment serves as audit trail.'
              })
            }

Dashboard mensal de saúde de dependências

Rode esta query GraphQL via gh api graphql -f query=@query.graphql num workflow agendado. Gera uma tabela Markdown pro canal do time.

# query.graphql
query($owner: String!, $repo: String!, $since: DateTime!) {
  repository(owner: $owner, name: $repo) {
    pullRequests(first: 100, states: [MERGED], orderBy: {field: MERGED_AT, direction: DESC}, mergedAfter: $since) {
      nodes {
        number
        title
        mergedAt
        author { login }
        labels(first: 10) { nodes { name } }
        reviews(first: 5) { nodes { state author { login } } }
        commits(first: 1) { nodes { commit { statusCheckRollup { state } } } }
        files(first: 5) { nodes { path } }
      }
    }
  }
}

Agende:

# .github/workflows/dependency-health-report.yml
on:
  schedule:
    - cron: '0 9 1 * *'  # First of month, 09:00 UTC
jobs:
  report:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    steps:
      - uses: actions/github-script@v7
        id: query
        with:
          script: |
            const fs = require('fs')
            const query = fs.readFileSync('.github/query.graphql', 'utf8')
            const since = new Date(Date.now() - 30*24*60*60*1000).toISOString()
            const result = await github.graphql(query, { owner: context.repo.owner, repo: context.repo.repo, since })
            return result
      - name: Format and post
        run: |
          # Parse JSON, build markdown table, post to Slack/Discord via webhook
          # See gist.github.com/yourhandle/dependency-health-report for full script
          echo "Report generated: ${{ steps.query.outputs.result }}"

Checklist no template de PR

Cole isso em .github/PULL_REQUEST_TEMPLATE/dependency-update.md:

## Dependency Update Checklist
 
- [ ] **Type identified**: `patch` | `minor` | `major` | `security` | `dev`
- [ ] **Changelog reviewed**: Link to upstream release notes: _______________
- [ ] **Breaking changes assessed**: None | Documented in PR description | Migration script added
- [ ] **Tests updated**: Unit | Contract | Integration | E2E | N/A (dev dep)
- [ ] **Local repro verified**: `./repro.sh` passes with new version
- [ ] **Security scan clean**: `ci/security-scan` passes
- [ ] **Required reviewers assigned**: Per CODEOWNERS (auto-assigned)
- [ ] **Auto-merge eligible**: All status checks green, no review changes requested
- [ ] **Hotfix?**: If yes — incident ticket linked: INC-______, revert PR planned by: ________
 
---
 
**Reviewer acknowledgment**: By approving, I confirm I've read the changelog and verified the test coverage for this change.

Sem ambiguidade. Sem "achei que alguém tinha checado." O checklist mora no PR. A política mora na config. O pager fica quieto.

Comentários

Vai direto ao ponto — dúvidas, correções e causos de produção são bem-vindos.

Carregando comentários…

Publicado em lacorte.dev