O artigo O Que é CI/CD e Por Que Sua Empresa Precisa Disso estabeleceu os fundamentos conceituais do CI/CD. Este artigo é inteiramente prático: será construído um pipeline completo e funcional usando GitHub Actions, cobrindo todas as etapas de um fluxo de entrega real — desde a execução de testes até o deploy automático em um servidor.
O projeto de exemplo será uma API Node.js com Express, PostgreSQL e testes automatizados. A estrutura é simples o suficiente para ser compreendida completamente, mas realista o suficiente para representar o que se encontra em projetos de produção.
Estrutura do Projeto de Exemplo
minha-api/
├── src/
│ ├── index.js
│ ├── routes/
│ │ └── users.js
│ └── db/
│ └── connection.js
├── tests/
│ ├── unit/
│ │ └── users.test.js
│ └── integration/
│ └── users.api.test.js
├── Dockerfile
├── docker-compose.yml
├── docker-compose.test.yml
├── package.json
└── .github/
└── workflows/
├── ci.yml
└── deploy.yml
O Arquivo de CI: Qualidade em Cada Push
O primeiro workflow garante qualidade do código a cada push ou Pull Request:
# .github/workflows/ci.yml
name: CI — Integração Contínua
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
NODE_VERSION: '20.x'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ── Job 1: Qualidade de Código ───────────────────
lint-e-analise:
name: Lint e Análise Estática
runs-on: ubuntu-latest
steps:
- name: Clona o repositório
uses: actions/checkout@v4
- name: Configura Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Instala dependências
run: npm ci
- name: Executa o linter
run: npm run lint
- name: Verifica formatação com Prettier
run: npm run format:check
# ── Job 2: Testes Unitários ──────────────────────
testes-unitarios:
name: Testes Unitários
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Executa testes unitários com cobertura
run: npm run test:unit -- --coverage
- name: Publica relatório de cobertura
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
- name: Verifica cobertura mínima
run: |
COVERAGE=$(cat coverage/coverage-summary.json | \
node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); \
console.log(JSON.parse(d).total.lines.pct)")
echo "Cobertura de linhas: $COVERAGE%"
node -e "if ($COVERAGE < 80) { \
console.error('Cobertura abaixo de 80%'); process.exit(1); }"
# ── Job 3: Testes de Integração ──────────────────
testes-integracao:
name: Testes de Integração
runs-on: ubuntu-latest
needs: testes-unitarios
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test123
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Executa migrações de teste
env:
DATABASE_URL: postgresql://test:test123@localhost:5432/testdb
run: npm run db:migrate
- name: Executa testes de integração
env:
DATABASE_URL: postgresql://test:test123@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
NODE_ENV: test
run: npm run test:integration
# ── Job 4: Build da Imagem Docker ────────────────
build-docker:
name: Build da Imagem Docker
runs-on: ubuntu-latest
needs: [ lint-e-analise, testes-integracao ]
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Configura Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login no GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extrai metadados
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-
type=semver,pattern={{version}}
- name: Escaneia vulnerabilidades antes do build
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: CRITICAL
exit-code: 1
- name: Constrói e publica imagem
id: build
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
O Arquivo de Deploy: Entregando em Produção
O segundo workflow cuida do deploy após o merge na main:
# .github/workflows/deploy.yml
name: Deploy — Entrega Contínua
on:
workflow_run:
workflows: ["CI — Integração Contínua"]
types: [completed]
branches: [main]
jobs:
deploy-staging:
name: Deploy em Staging
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
environment:
name: staging
url: https://staging.minha-api.com
steps:
- uses: actions/checkout@v4
- name: Configura acesso SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGING_SSH_KEY }}
- name: Adiciona host ao known_hosts
run: |
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via SSH
env:
IMAGE: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
HOST: ${{ secrets.STAGING_HOST }}
USER: ${{ secrets.STAGING_USER }}
run: |
ssh $USER@$HOST << EOF
# Atualiza a imagem
docker pull $IMAGE
# Para o container atual
docker stop api-staging || true
docker rm api-staging || true
# Sobe o novo container
docker run -d \
--name api-staging \
--network rede-producao \
-e NODE_ENV=staging \
-e DATABASE_URL=${{ secrets.STAGING_DATABASE_URL }} \
-e REDIS_URL=${{ secrets.STAGING_REDIS_URL }} \
--restart unless-stopped \
-p 3000:3000 \
$IMAGE
# Aguarda o healthcheck
sleep 10
docker inspect --format='{{.State.Health.Status}}' api-staging
EOF
- name: Verifica deploy
run: |
sleep 15
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
https://staging.minha-api.com/health)
if [ "$STATUS" != "200" ]; then
echo "Deploy falhou — status HTTP: $STATUS"
exit 1
fi
echo "Deploy bem-sucedido — status HTTP: $STATUS"
deploy-producao:
name: Deploy em Produção
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://api.minha-api.com
steps:
- uses: actions/checkout@v4
- name: Configura acesso SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_KEY }}
- name: Adiciona host ao known_hosts
run: |
ssh-keyscan -H ${{ secrets.PROD_HOST }} >> ~/.ssh/known_hosts
- name: Deploy com zero downtime
env:
IMAGE: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
HOST: ${{ secrets.PROD_HOST }}
USER: ${{ secrets.PROD_USER }}
run: |
ssh $USER@$HOST << 'EOF'
set -e
IMAGE="${{ env.IMAGE }}"
# Baixa a nova imagem antes de parar a atual
docker pull $IMAGE
# Sobe o novo container na porta alternativa
docker run -d \
--name api-prod-new \
--network rede-producao \
-e NODE_ENV=production \
-e DATABASE_URL=$PROD_DATABASE_URL \
-e REDIS_URL=$PROD_REDIS_URL \
--restart unless-stopped \
-p 3001:3000 \
$IMAGE
# Aguarda o novo container ficar saudável
for i in $(seq 1 12); do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' api-prod-new 2>/dev/null)
if [ "$HEALTH" = "healthy" ]; then
echo "Novo container saudável após ${i}0 segundos"
break
fi
echo "Aguardando... ($i/12)"
sleep 10
done
if [ "$HEALTH" != "healthy" ]; then
echo "Novo container não ficou saudável — abortando deploy"
docker rm -f api-prod-new
exit 1
fi
# Redireciona tráfego para o novo container (via nginx reload)
sudo nginx -s reload
# Para e remove o container antigo
docker stop api-prod || true
docker rm api-prod || true
# Renomeia o novo para o nome padrão
docker rename api-prod-new api-prod
echo "Deploy concluído com sucesso"
EOF
- name: Notifica o time no Slack
if: always()
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy em produção: ${{ job.status }}\nCommit: ${{ github.sha }}\nAutor: ${{ github.actor }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Environments: Ambientes com Aprovação Manual
O GitHub Actions oferece o conceito de environments — ambientes configuráveis com regras de proteção, secrets específicos e URL de destino. Para o ambiente de produção, é possível exigir aprovação manual antes do deploy:
No repositório, navega-se até Settings → Environments → New environment. Cria-se o ambiente production e ativa-se a opção Required reviewers, adicionando os membros da equipe que podem aprovar deploys.
Com isso configurado, o pipeline pausa antes do job de deploy em produção e envia uma notificação para os revisores aprovarem. O deploy só prossegue após a aprovação explícita.
Reutilizando Workflows com Reusable Workflows
Em projetos com múltiplos repositórios, duplicar o mesmo workflow de CI em cada um é impraticável. O GitHub Actions suporta reusable workflows — workflows que podem ser chamados por outros workflows:
# .github/workflows/ci-reusavel.yml — no repositório central
name: CI Reutilizável
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20.x'
secrets:
REGISTRY_TOKEN:
required: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
# Em qualquer outro repositório
name: CI
on: [push]
jobs:
ci:
uses: minha-org/workflows/.github/workflows/ci-reusavel.yml@main
with:
node-version: '20.x'
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Depurando Pipelines que Falham
Quando um pipeline falha e a saída dos logs não é suficiente para diagnosticar o problema, o GitHub Actions oferece a possibilidade de abrir uma sessão SSH interativa dentro do runner usando a action tmate:
- name: Abre sessão de debug SSH
uses: mxschmitt/action-tmate@v3
if: ${{ failure() }}
with:
limit-access-to-actor: true
Quando o step anterior falhar, esse step abre uma sessão SSH temporária e imprime o endereço de conexão nos logs. O desenvolvedor pode então conectar ao runner e inspecionar o ambiente como se fosse uma máquina qualquer — verificar arquivos, rodar comandos, investigar variáveis de ambiente.
O Que Vem a Seguir
O próximo artigo aprofunda um tema central do pipeline construído aqui: testes automatizados. Serão abordadas as diferentes categorias de testes, como organizá-los para máxima eficiência no pipeline, como lidar com testes flaky e como escrever testes que realmente dão confiança para fazer deploy.
Referências para Aprofundamento
Documentação oficial
- GitHub Actions — Workflow Syntax — Referência completa de toda a sintaxe disponível em arquivos de workflow.
- GitHub Actions — Reusable Workflows — Documentação completa sobre como criar e consumir workflows reutilizáveis.
- GitHub Actions — Environments — Guia completo sobre configuração de ambientes com proteções e aprovações manuais.
Ferramentas usadas no artigo
- webfactory/ssh-agent — GitHub — Action para configurar o agente SSH no runner com suporte a múltiplas chaves.
- mxschmitt/action-tmate — GitHub — Action para abrir sessões de debug SSH interativas dentro de runners do GitHub Actions.
- slackapi/slack-github-action — GitHub — Action oficial do Slack para enviar notificações de workflows.
Leitura complementar
- Act — Rodando GitHub Actions Localmente — Ferramenta para executar workflows do GitHub Actions localmente antes de fazer push, acelerando o ciclo de desenvolvimento do pipeline.
Exercícios
Exercício 1
O que o bloco services: faz no job de testes de integração? Por que a DATABASE_URL aponta para localhost:5432, e não para um nome de container?
Ver resposta
✓ Resposta: O services: sobe containers auxiliares que ficam disponíveis durante toda a execução do job — aqui, um PostgreSQL e um Redis de verdade, para que os testes de integração exercitem o caminho real em vez de mocks. As options com --health-cmd fazem o runner aguardar cada serviço ficar saudável antes de iniciar os steps, resolvendo a mesma corrida que o condition: service_healthy resolve no Compose. E a DATABASE_URL aponta para localhost porque o GitHub publica as portas declaradas em ports: diretamente no runner: do ponto de vista dos steps, o banco está na própria máquina, não em um host de rede separado.
Exercício 2
Descreva a ordem de execução do pipeline de CI, considerando que testes-integracao declara needs: testes-unitarios e build-docker declara needs: [lint-e-analise, testes-integracao]. Que lógica econômica há nessa ordem?
Ver resposta
✓ Resposta: lint-e-analise e testes-unitarios não têm dependências e partem em paralelo assim que o workflow dispara. testes-integracao aguarda os unitários concluírem com sucesso. build-docker aguarda os dois ramos convergirem — lint de um lado, integração do outro. A lógica econômica é ordenar por custo crescente: lint e testes unitários são rápidos e baratos, e falham em segundos quando há problema; testes de integração exigem subir banco e cache; o build é o mais caro de todos, porque compila para duas arquiteturas e publica no registro. Colocar o caro no fim significa que a maioria das falhas é descoberta antes de gastar os minutos mais custosos do pipeline.
Exercício 3
Por que o scan do Trivy roda antes do build, usando scan-type: fs? E qual o papel do exit-code: 1?
Ver resposta
✓ Resposta: O scan-type: fs examina o código-fonte e os manifestos de dependência no sistema de arquivos, e não uma imagem pronta — por isso ele pode e deve rodar antes de qualquer imagem existir. Antecipá-lo evita construir para duas arquiteturas, publicar no registro e só então descobrir uma vulnerabilidade crítica que invalida tudo o que acabou de ser feito. O exit-code: 1 é o que transforma o scan em porta de verdade: sem ele, o Trivy imprimiria o relatório, encerraria com sucesso e o pipeline seguiria adiante normalmente. Um relatório que não bloqueia nada depende de alguém ler os logs — e ninguém lê logs de job que passou.
Exercício 4
Para que serve o bloco outputs: do job build-docker, dado que cada job roda isolado? Por que expor o digest, e não apenas a tag?
Ver resposta
✓ Resposta: Justamente porque cada job roda em uma máquina virtual própria, descartada ao final — nada atravessa essa fronteira por acaso. Os outputs são o mecanismo explícito de passar valores adiante, e aqui carregam a tag e o digest da imagem recém-construída, para que um job de deploy saiba exatamente o que publicar. Expor o digest importa porque ele é o identificador imutável do conteúdo da imagem: a tag é um rótulo que pode ser reescrito para apontar a outro artefato, enquanto o digest garante que o que vai para produção é bit a bit o mesmo artefato que passou pelos testes.
Exercício 5
O bloco environment: name: staging faz mais do que documentar o destino. O que ele habilita — e qual conceito do artigo anterior ele materializa?
Ver resposta
✓ Resposta: Environments são um mecanismo de controle, não um rótulo. Eles permitem secrets com escopo próprio — a STAGING_SSH_KEY não precisa ser, e não deve ser, a mesma chave de produção — e admitem regras de proteção: exigência de aprovação manual antes do job iniciar, restrição de quais branches podem alcançar aquele ambiente, e tempo mínimo de espera. É exatamente isso que materializa a Entrega Contínua descrita no artigo anterior: o pipeline automatiza tudo até a porta da produção e para ali, aguardando o gatilho humano. A url, por sua vez, faz o GitHub exibir o link do ambiente implantado na própria interface do repositório.
Exercício 6
No deploy via SSH, o que docker stop api-staging || true resolve? E qual é a fraqueza estrutural dessa estratégia de deploy?
Ver resposta
✓ Resposta: O || true impede que o comando derrube o script quando o container ainda não existe — na primeira execução, ou depois de uma limpeza manual, o docker stop falharia e abortaria o deploy inteiro por um erro que não é erro nenhum. A fraqueza estrutural está no que vem depois: entre parar o container antigo e o novo passar no healthcheck, o serviço fica fora do ar. E se a imagem nova subir quebrada, não há volta automática, porque o container anterior já foi removido — o rollback vira uma operação manual justamente no pior momento. Estratégias como blue-green e rolling existem exatamente para eliminar essa janela, mantendo a versão antiga de pé até que a nova prove que funciona.