Repository navigation
CI CD e Deploy
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.
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.
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 comandoEle 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
|
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"]
| 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).
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: readno workflow inteiro. OGITHUB_TOKENnão pode escrever nada. -
concurrencycomcancel-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-minutesem 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 odotnet formatainda acusa diferenças que o build aceita (ordem deusingem alguns testes e quebra de linha no fim de arquivo). Adicionar essa verificação exige limpar o código antes.
Quatro jobs: o CI, dois jobs de empacotamento em paralelo e o deploy. Se um falha, o deploy não roda.
ci:
uses: ./.github/workflows/ci.ymlNada vai para produção sem passar pelos mesmos testes de um pull request.
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/efbundleO 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) eorg.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 oGITHUB_TOKENtemporá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.
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>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.
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
- 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). Obootstrap.shpergunta 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: writesó 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.
| 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.
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
| 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.
| 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.
Pré-requisitos: Azure CLI e GitHub CLI autenticados (az login e gh auth login) e uma assinatura da Azure ativa.
./infra/bootstrap.shO script, que pode ser rodado de novo sem duplicar nada:
- Cria o grupo de recursos
rg-policydeskemwestus2(oeste dos EUA). Veja abaixo por que essa região. - Gera uma senha aleatória para o banco e aplica o
main.bicep(banco, plano F1 e Web App, com a connection string configurada). - Cria a identidade do GitHub Actions, a federated credential do environment
productione o papelWebsite Contributorno Web App. - Configura o environment
productionno GitHub para aceitar deploy só damain. - 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.
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).
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:latestSe 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).
| 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
|
- Execuções: aba Actions do repositório. O resumo de cada CI mostra a cobertura.
-
Deploys: o environment
productionregistra cada deploy, com o commit e a URL. - Logs da API em produção: veja Observabilidade › Em produção.
-
Fixar as Actions por SHA (
actions/checkout@<sha>) em vez de@v4. Protege contra uma tag movida maliciosamente; o Dependabot continua atualizando. -
Aprovação manual no environment
production(required reviewers), para que o deploy espere um clique. -
Verificação de formatação no CI, depois de alinhar o código ao
dotnet format. - Varredura da imagem com uma ferramenta como o Trivy, para encontrar vulnerabilidades nos pacotes do sistema da imagem antes do deploy.
-
Ambiente de homologação, com deploy automático a partir da
developer.
- 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.
- 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.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta