Pipeline com GitHub Actions: Build, Test e Deploy Automático

[288] Pipeline com GitHub Actions: Build, Test e Deploy Automático

Um pipeline completo com GitHub Actions: jobs de lint, testes unitários e de integração com banco e cache reais, verificação de cobertura mínima, scan de vulnerabilidades como porta de bloqueio, build e publicação da imagem, e o workflow de deploy com environments, SSH e verificação pós-deploy.
DevOps

13 min de leitura

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

Ferramentas usadas no artigo

Leitura complementar

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.

Comentários

Mais em DevOps

Docker Volumes e Redes: Persistência e Comunicação
Docker Volumes e Redes: Persistência e Comunicação

Volumes e redes no Docker: por que o sistema de arquivos do container é…

Navegando pelo Sistema de Arquivos com Confiança
Navegando pelo Sistema de Arquivos com Confiança

A árvore de diretórios do Linux e o que cada ramo guarda: /etc, /var, /home…

GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions
GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions

A plataforma que nasceu DevOps completa, e não como hospedagem de código que…