O artigo anterior 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.