Skip to content

CI CD e Deploy

rodrigosdk edited this page Nov 12, 2026 · 7 revisions

CI/CD e deploy: do commit à produção, sem passos manuais

Para quem é este artigo: para quem quer entender como o PolicyDesk é verificado e publicado, e para quem está aprendendo CI/CD, GitHub Actions e Azure.

Pré-requisitos: saber o que é um commit e um pull request no Git. O resto é explicado do zero.

O que você vai aprender: o que são CI e CD, os conceitos do GitHub Actions, cada passo do pipeline do PolicyDesk, como o deploy entra na Azure sem guardar senhas, a infraestrutura como código com Bicep e como fazer o primeiro deploy.


O problema: "funciona na minha máquina"

Sem automação, publicar uma versão nova é uma lista de passos manuais: compilar, rodar os testes (se alguém lembrar), gerar o pacote, entrar no servidor, copiar os arquivos, atualizar o banco, reiniciar. Cada passo é uma chance de erro, e cada pessoa faz de um jeito. Pior: quem garante que o código que foi para produção é o mesmo que passou nos testes?

CI/CD é a resposta da indústria para isso:

  • CI, Integração Contínua (Continuous Integration): a cada mudança enviada ao repositório, um servidor compila o código e roda todos os testes automaticamente. Se algo quebrar, a equipe sabe em minutos, e não na semana do lançamento.
  • CD, Entrega Contínua (Continuous Delivery/Deployment): toda versão que passa no CI é empacotada e publicada automaticamente, sempre pelo mesmo caminho.

O resultado: publicar deixa de ser um evento arriscado e vira algo rotineiro, que acontece a cada merge.


GitHub Actions em cinco minutos

O PolicyDesk usa o GitHub Actions, o serviço de automação do próprio GitHub. Os conceitos:

Conceito O que é No PolicyDesk
Workflow Um processo automatizado, descrito em um arquivo YAML em .github/workflows/ ci.yml, cd.yml, codeql.yml
Evento (on:) O que dispara o workflow Um push, um pull request, um horário, um clique manual
Job Um conjunto de passos que roda em uma máquina Build e testes, Imagem Docker, Deploy em produção
Runner A máquina virtual que executa o job, criada do zero e descartada no final ubuntu-latest, já com Docker e Git
Step Um passo dentro do job: um comando ou uma action dotnet build, dotnet test
Action Um passo pronto e reutilizável, publicado por alguém actions/checkout@v4 baixa o código; actions/setup-dotnet@v4 instala o .NET
Artefato Um arquivo produzido por um job e guardado para os próximos jobs (ou para download) O bundle de migrations, o relatório de cobertura
Secret Um valor sensível, guardado criptografado pelo GitHub e nunca exibido nos logs AZURE_CLIENT_ID, DATABASE_CONNECTION_STRING
Environment Um ambiente de destino (como "produção"), com secrets próprios e regras de proteção production, que só aceita deploy a partir da main

Um workflow mínimo, para fixar a sintaxe:

name: CI
on:
  pull_request:            # dispara em todo pull request
jobs:
  build:
    runs-on: ubuntu-latest # a máquina
    steps:
      - uses: actions/checkout@v4    # uma action pronta: baixa o código
      - run: dotnet test             # um comando

O pipeline do PolicyDesk

Ele publica a API em um Azure App Service Linux no plano F1 (gratuito), com um Azure Database for PostgreSQL Flexible Server.

Arquivos envolvidos:

Arquivo Papel
.github/workflows/ci.yml Integração contínua: build, auditoria de pacotes, testes e cobertura
.github/workflows/cd.yml Entrega contínua: empacota e publica na Azure
.github/workflows/codeql.yml Análise estática de segurança
.github/dependabot.yml Atualização automática de dependências
infra/main.bicep Infraestrutura como código (banco, plano F1, Web App)
infra/bootstrap.sh Provisiona a Azure e configura o GitHub com um comando
global.json Fixa a versão do SDK do .NET
.config/dotnet-tools.json Fixa a versão do dotnet-ef

Visão geral do fluxo

flowchart LR
    PR["Pull request<br/>ou push em developer"] --> CI
    MAIN["Merge em main"] --> CD_CI

    subgraph CD["cd.yml"]
        CD_CI["CI<br/>(reutilizado)"] --> MIG["Bundle de migrations"]
        CD_CI --> IMG["Imagem Docker<br/>ghcr.io"]
        MIG --> DEP["Deploy (environment production)<br/>login OIDC · migrations<br/>imagem no App Service · smoke test"]
        IMG --> DEP
    end

    CI["<b>ci.yml</b><br/>restore · vulnerabilidades<br/>build · testes · cobertura"]
Loading
Evento O que roda
Pull request para main ou developer CI e CodeQL
Push em developer CI e CodeQL
Push em main (merge) CD (que inclui o CI) e CodeQL
Toda segunda-feira CodeQL e Dependabot
Manual (workflow_dispatch) CI ou CD, pela aba Actions

Mudanças só em Markdown, docs/ ou LICENSE não disparam CI nem CD (paths-ignore).


CI: ci.yml

Um único job, Build e testes, em ubuntu-latest:

Passo Comando Por quê
SDK actions/setup-dotnet com global-json-file A mesma versão do SDK local, sem número repetido no YAML
Cache actions/cache em ~/.nuget/packages A chave usa Directory.Packages.props e os .csproj: muda só quando as dependências mudam
Restore dotnet restore Separado do build, para o cache ter efeito
Vulnerabilidades dotnet list package --vulnerable --include-transitive Falha se algum pacote, mesmo indireto, tiver vulnerabilidade conhecida
Build dotnet build -c Release Com TreatWarningsAsErrors, qualquer aviso de analisador ou de estilo quebra o CI
Testes dotnet test --collect "XPlat Code Coverage" Os quatro projetos. O runner tem Docker, então os testes com Testcontainers rodam contra um PostgreSQL de verdade
Cobertura ReportGenerator Consolida os relatórios e publica o resumo na página da execução
Artefatos actions/upload-artifact Resultados .trx e o relatório HTML de cobertura, por 14 dias

Decisões:

  • permissions: contents: read no workflow inteiro. O GITHUB_TOKEN não pode escrever nada.
  • concurrency com cancel-in-progress: um push novo na mesma branch cancela a execução anterior, que já ficou obsoleta.
  • workflow_call: o CD reaproveita este mesmo workflow, em vez de copiar os passos.
  • timeout-minutes em todos os jobs. Um teste travado não consome horas de runner.

O CI não roda dotnet format --verify-no-changes. O build já aplica as regras de estilo do .editorconfig (EnforceCodeStyleInBuild), e o dotnet format ainda acusa diferenças que o build aceita (ordem de using em alguns testes e quebra de linha no fim de arquivo). Adicionar essa verificação exige limpar o código antes.


CD: cd.yml

Quatro jobs: o CI, dois jobs de empacotamento em paralelo e o deploy. Se um falha, o deploy não roda.

1. CI

ci:
  uses: ./.github/workflows/ci.yml

Nada vai para produção sem passar pelos mesmos testes de um pull request.

2. Bundle de migrations e imagem Docker (em paralelo)

Depois do CI, dois jobs rodam ao mesmo tempo, porque um não depende do outro.

Bundle de migrations. Gera o migrations bundle, um executável autocontido com todas as migrations:

dotnet restore src/Backend/PolicyDesk.Infrastructure/PolicyDesk.Infrastructure.csproj --runtime linux-x64

dotnet ef migrations bundle --self-contained --runtime linux-x64 \
  --project src/Backend/PolicyDesk.Infrastructure \
  --startup-project src/Backend/PolicyDesk.Infrastructure \
  --output artifacts/migrations/efbundle

O dotnet restore --runtime linux-x64 é necessário porque o bundle autocontido precisa dos pacotes específicos de Linux. Sem ele, o job falha com NETSDK1047.

Imagem Docker. Gera a imagem da API a partir do mesmo docker/Dockerfile usado no desenvolvimento local, e a publica no GitHub Container Registry (ghcr.io):

- uses: docker/build-push-action@v6
  with:
    context: .
    file: docker/Dockerfile
    push: true
    tags: |
      ghcr.io/rodrigosdk/policydesk-api:<sha do commit>
      ghcr.io/rodrigosdk/policydesk-api:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max
  • Duas tags. A tag com o SHA do commit é imutável: ela identifica exatamente o código que está no ar, e é a que vai para produção. A tag latest é só um apelido para a mais recente.
  • Etiquetas OCI. A imagem leva org.opencontainers.image.source (que liga o pacote ao repositório no GitHub) e org.opencontainers.image.revision (o commit).
  • Cache. As camadas da imagem ficam no cache do GitHub Actions (type=gha). Se só o código mudou, a restauração dos pacotes NuGet não é refeita.
  • Permissões mínimas. Só esse job recebe packages: write, e ele usa o GITHUB_TOKEN temporário da execução para publicar. Nenhum segredo extra.

O princípio é build once, deploy many: o job de deploy não compila nada. Ele usa o bundle e a imagem gerados aqui, que são exatamente o que passou no CI.

3. Deploy

Roda no environment production do GitHub, que guarda os secrets e só aceita deploy a partir da main.

Passo O que faz
Baixar o bundle O executável de migrations gerado no job anterior
Login na Azure azure/login com OIDC (sem senha)
Migrations ./efbundle --connection "$DB_CONNECTION", antes de trocar a versão da API
Deploy azure/webapps-deploy com images: aponta o Web App para a imagem nova (ghcr.io/...:<sha>); o App Service baixa a imagem e reinicia o contêiner
Smoke test curl --retry 18 em /health, que só responde 200 se a API subiu e o banco responde. As tentativas extras cobrem o tempo de baixar a imagem
Logout az logout, mesmo se algo falhar

O concurrency: deploy-production sem cancelamento garante que dois deploys nunca rodem ao mesmo tempo, e que um deploy em andamento não seja interrompido no meio.

Por que migrar antes de publicar? A versão nova da API pode depender de colunas novas. A ordem inversa deixaria a API nova no ar, por alguns segundos, com o banco antigo. Em troca, cada migration precisa ser compatível com a versão anterior da API, que continua no ar até o deploy terminar. Remover ou renomear uma coluna é feito em duas entregas (expand/contract). Mais em Persistência › Migrations bundle.

Voltar uma versão (rollback) é trocar a imagem pela tag de um commit anterior. Como as imagens antigas continuam no registro, não é preciso compilar nada:

az webapp config container set -g rg-policydesk -n policydesk-api-rodrigo \
  --container-image-name ghcr.io/rodrigosdk/policydesk-api:<sha-anterior>

Por que contêiner, e não o runtime .NET do App Service?

O App Service também sabe rodar uma aplicação .NET publicada diretamente (o modelo "código"). O PolicyDesk usa a imagem Docker porque:

Critério Contêiner (usado) Código no runtime do App Service
Igualdade com o ambiente local A mesma imagem do docker compose: mesmo sistema, mesmo usuário sem root, mesma porta O App Service usa o próprio ambiente
Portabilidade A imagem roda em qualquer nuvem ou servidor com Docker Preso ao App Service
Rollback Trocar a tag Republicar o pacote antigo
Atualizações de segurança do sistema e do runtime Responsabilidade do projeto: a tag 10.0 aponta sempre para a correção mais recente, e cada build do CD baixa a imagem atualizada Feitas pela Microsoft
Primeira inicialização Mais lenta: o App Service precisa baixar a imagem Mais rápida

Custo: zero. O GitHub Container Registry não cobra por imagens públicas, e o plano F1 aceita contêineres.

Autenticação na Azure com OIDC

O pipeline não guarda senha nem publish profile da Azure. Em cada execução:

sequenceDiagram
    participant GH as GitHub Actions
    participant ID as Microsoft Entra ID
    participant AZ as Azure

    GH->>GH: emite um token OIDC de curta duração<br/>(sub = repo:Rodrigosdk/PolicyDesk:environment:production)
    GH->>ID: troca o token (federated credential)
    ID-->>GH: token de acesso da identidade github-policydesk-api-rodrigo
    GH->>AZ: deploy com o token de acesso
Loading
  • A federated credential só aceita tokens do repositório e do environment production. Um workflow de outra branch, ou de um fork, não consegue o acesso.
  • O formato do subject depende do repositório. Repositórios mais novos usam o formato com os ids numéricos do dono e do repositório (repo:Rodrigosdk@61851910/PolicyDesk@1430040504:environment:production), que não muda se o repositório for renomeado. Os mais antigos usam só os nomes (repo:Rodrigosdk/PolicyDesk:environment:production). O bootstrap.sh pergunta ao GitHub qual formato o repositório usa (gh api repos/<repo>/actions/oidc/customization/sub) e cria a credencial com o subject exato.
  • A identidade tem só o papel Website Contributor, e só sobre o Web App. Não acessa o banco, outros recursos nem a assinatura.
  • O job pede id-token: write só para si. Os outros jobs não podem emitir tokens.

Os valores ficam no environment production:

Tipo Nome Conteúdo
Secret AZURE_CLIENT_ID Id da identidade do GitHub Actions
Secret AZURE_TENANT_ID Tenant do Entra ID
Secret AZURE_SUBSCRIPTION_ID Assinatura da Azure
Secret DATABASE_CONNECTION_STRING Connection string usada pelas migrations
Variable AZURE_WEBAPP_NAME policydesk-api-rodrigo

Se a extensão do GitHub Actions no editor acusar Context access might be invalid: AZURE_WEBAPP_NAME, é só porque a variável ainda não existe no repositório. O aviso some quando ela é cadastrada.


Segurança contínua

Ferramenta Quando O que encontra
dotnet list package --vulnerable Todo CI Pacotes NuGet com vulnerabilidade publicada (GitHub Advisory Database)
CodeQL (security-and-quality) PRs, pushes e semanalmente Injeção de SQL, caminhos inseguros, exceções engolidas e outros problemas no código C#
Dependabot Semanalmente PRs com atualizações de NuGet, Actions e imagens Docker, agrupadas e apontadas para developer

Os grupos do Dependabot (microsoft, opentelemetry, testes) juntam pacotes que costumam ser atualizados juntos. Uma versão nova do EF Core chega em um PR só, e não em seis.

O Dependabot não sobe a versão principal do .NET. Ele ignora as atualizações de versão principal dos pacotes Microsoft.*, System.*, Npgsql* e Swashbuckle.*, do dotnet-ef e das imagens dotnet/*. Correções e versões menores (10.0.x, 10.x) continuam chegando normalmente.

O motivo: esses pacotes acompanham a versão do .NET, e trocar de versão principal mexe ao mesmo tempo no TargetFramework, no global.json, nas imagens do Dockerfile e nos pacotes. Feito aos pedaços, em PRs separados, o resultado quebra. Um exemplo: uma imagem aspnet:11.0 não roda uma API net10.0, e o CI não percebe, porque testa o código e não o contêiner. A migração de versão do .NET é feita à mão, num PR único. Quando chegar a hora, basta remover essas regras do .github/dependabot.yml.

O Dependabot lê o dependabot.yml do branch padrão (main). Uma mudança nas regras só vale depois de chegar ao main.


Infraestrutura: infra/main.bicep

flowchart LR
    subgraph RG["rg-policydesk (westus2)"]
        PLAN["plan-policydesk-api-rodrigo<br/>App Service Plan F1 Linux"] --> APP["policydesk-api-rodrigo<br/>Web App (contêiner)"]
        PG["psql-policydesk-api-rodrigo<br/>PostgreSQL 17 · B1ms"] --> DB[("policydesk")]
    end
    APP -- "SSL Mode=Require" --> DB
    GH["GitHub Actions"] -- "migrations" --> DB
    GH -- "deploy" --> APP
    GHCR[("ghcr.io<br/>policydesk-api")] -- "imagem" --> APP
Loading
Recurso Configuração Por quê
PostgreSQL Flexible Server Standard_B1ms, 32 GB, PostgreSQL 17 B1ms faz parte da oferta gratuita de 12 meses da Azure
Firewall do banco AllowAllAzureServicesAndResourcesWithinAzureIps O App Service e os runners do GitHub (que rodam na Azure) alcançam o banco sem abrir para a internet toda. Rede privada exigiria um plano pago
App Service Plan F1, Linux Gratuito
Web App DOCKER|ghcr.io/rodrigosdk/policydesk-api, httpsOnly, TLS 1.2, FTP desligado Roda a imagem da API; só HTTPS, sem protocolos legados. O CD troca a tag pela do commit publicado
ASPNETCORE_ENVIRONMENT Production Sem migrations na inicialização
Swagger__Enabled true Liga o Swagger em produção e redireciona a raiz / para ele, para que a API possa ser testada pelo navegador
ASPNETCORE_FORWARDEDHEADERS_ENABLED true O App Service termina o TLS no balanceador. Sem isso, a API acharia que a requisição chegou por HTTP
WEBSITES_PORT 8080 A porta em que o contêiner escuta (ASPNETCORE_HTTP_PORTS no Dockerfile)
WEBSITES_ENABLE_APP_SERVICE_STORAGE false O contêiner não precisa do disco compartilhado do App Service; tudo o que importa fica no banco
DOCKER_REGISTRY_SERVER_URL https://ghcr.io De onde baixar a imagem. Sem usuário e senha, porque a imagem é pública
Connection string Tipo Custom, nome DbConnection Vira a variável CUSTOMCONNSTR_DbConnection, que o ASP.NET Core lê como ConnectionStrings:DbConnection

Bicep é a linguagem de infraestrutura como código da própria Azure. Ele é declarativo e idempotente: rodar de novo só ajusta o que mudou.

Limites do plano F1

Limite Consequência
60 minutos de CPU por dia Suficiente para demonstração; tráfego real esgota a cota
Sem Always On A aplicação dorme sem uso. A primeira requisição depois disso leva alguns segundos
Sem deployment slots Não há troca de slot (blue-green). Cada deploy tem um pequeno intervalo fora do ar, e o rollback é reimplantar a versão anterior
1 GB de memória e de disco Suficiente para a API

Em um ambiente real, o plano B1 ou superior habilita Always On, e o S1 habilita slots de deploy sem downtime.


Primeiro deploy

Pré-requisitos: Azure CLI e GitHub CLI autenticados (az login e gh auth login) e uma assinatura da Azure ativa.

./infra/bootstrap.sh

O script, que pode ser rodado de novo sem duplicar nada:

  1. Cria o grupo de recursos rg-policydesk em westus2 (oeste dos EUA). Veja abaixo por que essa região.
  2. Gera uma senha aleatória para o banco e aplica o main.bicep (banco, plano F1 e Web App, com a connection string configurada).
  3. Cria a identidade do GitHub Actions, a federated credential do environment production e o papel Website Contributor no Web App.
  4. Configura o environment production no GitHub para aceitar deploy só da main.
  5. Cadastra os quatro secrets e a variável do environment.

Depois disso, um merge em main publica a API em https://policydesk-api-rodrigo.azurewebsites.net.

Variáveis opcionais: APP_NAME (padrão policydesk-api-rodrigo), RESOURCE_GROUP, LOCATION, GITHUB_REPO e DB_ADMIN_PASSWORD. Rodar o script de novo sem DB_ADMIN_PASSWORD troca a senha do banco, e o script atualiza ao mesmo tempo o Web App e o secret.

Por que West US 2, e não Brazil South?

A escolha óbvia para usuários brasileiros seria brazilsouth, mas a assinatura do projeto só recebeu cota de F1 em westus2. Duas regras guiaram a decisão:

  • A API e o banco ficam na mesma região. Uma requisição faz várias consultas ao banco; se cada uma cruzasse o continente (uns 150 ms a mais por ida e volta), uma requisição simples levaria segundos.
  • A distância até o usuário pesa uma vez por requisição. Um usuário no Brasil espera cerca de 150 ms a mais por requisição, um custo aceitável para um ambiente gratuito de demonstração.

Para mover tudo para outra região, basta liberar a cota de F1 nela e rodar LOCATION=<região> ./infra/bootstrap.sh em um grupo de recursos novo (ou depois de apagar o atual).

A imagem é pública, e por quê

O App Service baixa a imagem sem usuário e senha, então ela precisa ser pública. Não é preciso configurar nada para isso: a imagem é publicada com a etiqueta org.opencontainers.image.source apontando para o repositório, e o GitHub Container Registry liga o pacote ao repositório e herda a visibilidade dele. Como o repositório é público, a imagem também nasce pública.

Isso não expõe nada que já não esteja no repositório: a imagem contém só a API compilada, sem segredos. A connection string e as senhas ficam nas configurações do App Service.

Para conferir sem login, do mesmo jeito que o App Service faz:

docker pull ghcr.io/rodrigosdk/policydesk-api:latest

Se o repositório fosse privado, a imagem também seria, e o App Service precisaria de credenciais (DOCKER_REGISTRY_SERVER_USERNAME e DOCKER_REGISTRY_SERVER_PASSWORD).

Problemas comuns

Erro Causa Solução
A conta logada não tem assinatura da Azure az login com uma conta sem assinatura Criar uma assinatura e fazer az login de novo
Current Limit (F1 VMs): 0 Assinaturas novas vêm com cota zero de App Service, e a cota é liberada por região Pedir a cota no portal (Quotas › App Service, limite 1 para F1) e rodar o script com LOCATION=<região liberada>
The selected region is currently not accepting new customers Região sem capacidade para assinaturas novas Usar outra região com LOCATION=...
Smoke test falha com 503 A API subiu, mas não alcança o banco Conferir o firewall do PostgreSQL e o secret DATABASE_CONNECTION_STRING
O deploy termina, mas o /health nunca responde, e o log do App Service mostra unauthorized ao baixar a imagem A imagem no ghcr.io está privada (por exemplo, se o repositório virou privado) Torná-la pública em Package settings ou configurar credenciais no App Service, e rodar o job de deploy de novo
AADSTS700213: No matching federated identity record found no login O subject enviado pelo GitHub não bate com o da credencial federada Comparar o subject do log com az ad app federated-credential list e rodar o bootstrap.sh de novo, que cria a credencial no formato certo
Migrations falham com timeout O runner não alcança o banco Conferir a regra AllowAllAzureServicesAndResourcesWithinAzureIps

Como acompanhar

  • Execuções: aba Actions do repositório. O resumo de cada CI mostra a cobertura.
  • Deploys: o environment production registra cada deploy, com o commit e a URL.
  • Logs da API em produção: veja Observabilidade › Em produção.

Melhorias possíveis

  1. Fixar as Actions por SHA (actions/checkout@<sha>) em vez de @v4. Protege contra uma tag movida maliciosamente; o Dependabot continua atualizando.
  2. Aprovação manual no environment production (required reviewers), para que o deploy espere um clique.
  3. Verificação de formatação no CI, depois de alinhar o código ao dotnet format.
  4. Varredura da imagem com uma ferramenta como o Trivy, para encontrar vulnerabilidades nos pacotes do sistema da imagem antes do deploy.
  5. Ambiente de homologação, com deploy automático a partir da developer.

Resumo

  • CI compila e testa cada mudança; CD publica cada versão aprovada, sempre pelo mesmo caminho.
  • O CD reaproveita o CI, gera o bundle e a imagem Docker uma vez, e publica exatamente o que foi testado.
  • O login na Azure usa OIDC: nenhuma senha de longa duração fica guardada, e a identidade só pode mexer no Web App.
  • As migrations rodam antes de publicar a API, e um smoke test confirma que tudo subiu.
  • A infraestrutura é código (Bicep), e um único script prepara a Azure e o GitHub.

Para aprofundar

  • Documentação do GitHub Actions: "Understanding GitHub Actions" e "Security hardening with OpenID Connect".
  • Documentação da Azure: "Deploy to App Service using GitHub Actions" e "What is Bicep?".
  • Continuous Delivery, Jez Humble e David Farley.
  • Accelerate, Nicole Forsgren, Jez Humble e Gene Kim: a pesquisa que relaciona práticas de entrega com o desempenho das equipes.

Clone this wiki locally