ADR-0016 CI/CD
Norma da empresa para CI/CD — um workflow portável que corre em GitHub Actions e Forgejo Actions (uma microVM Firecracker efémera por job), uma imagem imutável por commit, implementada pelo Nodus.
- Decisão
- Um workflow para GitHub + Forgejo; Nodus faz o deployEntregar e operar
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Apoia-se em
Contexto
Seção intitulada “Contexto”Os nossos repositórios vivem no GitHub ou no nosso Forgejo, e uma app pode mudar de um para o outro. Ambos correm workflows ao estilo Actions, mas não são idênticos:
- O Forgejo Actions procura a familiaridade, não a compatibilidade. Lê
.forgejo/workflows/, recorrendo a.github/workflows/apenas quando.forgejo/workflows/não existe. As referências curtas emuses:resolvem contrahttps://data.forgejo.org(um espelho de actions comuns), não contra o GitHub.permissionsecontinue-on-errorao nível do job são ignorados, e o OIDC usaenable-openid-connectem vez depermissions: id-token: write - O GitHub só aceita
owner/repo@refemuses:— sem URLs completos — pelo que uma action de terceiros que não esteja espelhada em data.forgejo.org não pode ser referenciada da mesma forma nos dois - Os runners diferem: o
ubuntu-latestalojado do GitHub é uma VM nova por job, com Docker; o Forgejo Runner corre os jobs num contentor (Debian com Node por predefinição) numa máquina de longa duração, e o Docker dentro dos jobs exige configuração do runner
Versões atuais: Forgejo 17.0 (estável), 15.0 LTS (suportado até 2027-07-15); Forgejo Runner 13.2.
O deploy é feito pelo Nodus, a nossa plataforma proprietária. Este ADR cobre tudo até uma imagem publicada, mais o contrato que essa imagem tem de cumprir para o Nodus. A forma como o Nodus é acionado, e como promove e faz rollback, é definida pelo Nodus.
O que o CI tem de correr já está decidido: bun run check, bun test para testes unitários e de integração, e o E2E com Playwright (ADR-0035, ADR-0014). (Alterado em 2026-11-13: este ADR instalava primeiro o Vite+ e o Node e corria em Debian slim; o ADR-0035 moveu a toolchain para o Bun, e a imagem de runtime é agora a imagem distroless do Bun, executada como utilizador não-root.)
Opções consideradas
Seção intitulada “Opções consideradas”Workflows em dois forges
Seção intitulada “Workflows em dois forges”1. Um conjunto de workflows portável em .github/workflows/
Seção intitulada “1. Um conjunto de workflows portável em .github/workflows/”Prós: um ficheiro para manter; o Forgejo lê-o através do seu fallback. A lógica vive em scripts bun run, pelo que o YAML fica fino.
Contras: limitado ao subconjunto comum — sem actions de terceiros além das espelhadas em data.forgejo.org, sem funcionalidades específicas de cada forge.
2. Dois conjuntos de workflows (.github/workflows/ e .forgejo/workflows/)
Seção intitulada “2. Dois conjuntos de workflows (.github/workflows/ e .forgejo/workflows/)”Prós: cada forge usa as suas melhores funcionalidades (p. ex. oven-sh/setup-bun no GitHub).
Contras: duas pipelines que divergem; cada alteração é feita duas vezes.
3. Apenas um forge
Seção intitulada “3. Apenas um forge”Prós: sem trabalho de compatibilidade. Contras: não corresponde ao sítio onde os nossos repositórios realmente vivem.
Instalar a toolchain no CI
Seção intitulada “Instalar a toolchain no CI”oven-sh/setup-bun— a action oficial, com cache, e lê opackageManager; mas é uma action de terceiros, pelo que no Forgejo só resolve se o data.forgejo.org a espelharoven/buncomo contentor do job — a imagem oficial; mas os jobs que precisam de Docker (Testcontainers, E2E, builds de imagens) precisariam de ter o socket Docker do host montado lá dentro- O instalador oficial num passo
run:(curl -fsSL https://bun.sh/install | bash -s bun-v<version>) — shell simples, idêntico nos dois forges
Isolar os jobs do Forgejo
Seção intitulada “Isolar os jobs do Forgejo”O Forgejo Runner executa tudo o que um workflow disser — execução remota de código por design. Os runners alojados do GitHub já dão a cada job uma VM nova; no Forgejo, esse isolamento fica por nossa conta.
- Runner persistente em contentores, Docker-in-Docker — a configuração por omissão; mas os contentores partilham o kernel do host, e o estado (caches, imagens, ficheiros deixados para trás, credenciais) sobrevive de um job para o seguinte
- Labels de runner LXC — mais próximo de uma VM, mas ainda com kernel partilhado
- Uma microVM Firecracker efémera por job — isolamento virtualizado por hardware, com o seu próprio kernel e o seu próprio Docker engine, arrancada em segundos a partir de uma imagem limpa e destruída depois do job. O Forgejo suporta este modelo nativamente: um runner efémero corre no máximo um job antes de o Forgejo apagar o seu registo, o que é imposto do lado do servidor. Precisa de hosts KVM e de um orquestrador que operamos nós
Decisão
Seção intitulada “Decisão”Um conjunto de workflows portável em .github/workflows/, a correr tanto no GitHub Actions como no Forgejo Actions, constrói uma imagem imutável por commit para o registry do próprio forge; o Nodus faz o deploy dela. Esta é a norma da empresa para todas as novas apps.
Workflows
Seção intitulada “Workflows”-
Apenas
.github/workflows/. Nunca adicionar.forgejo/workflows/— a sua presença faz o Forgejo ignorar.github/workflows/ -
Apenas o subconjunto comum:
runs-on: ubuntu-latest(os nossos runners Forgejo registam essa label), contextosgithub.*(o Forgejo fornece-os) eactions/checkoutcomo única action. Tudo o resto é um passorun:que chamabun -
Actions fixadas por SHA de commit completo com a tag num comentário. O data.forgejo.org espelha os commits upstream, pelo que um SHA resolve nos dois (verificado para
actions/checkoutv7.0.1) -
Bun instalado pelo seu instalador oficial na versão exata do campo
packageManagerdopackage.jsonda raiz (ADR-0035), e depoisbun ci, que falha se obun.lockestiver desatualizado -
Triggers:
pull_requestepushparamain.concurrencypor ref, cancelando as execuções ultrapassadas apenas em pull requests -
Jobs:
Job Corre (depois de bun ci)Precisa de Docker checkbun run check,bun run testNão integrationbun run test:intSim e2ebunx playwright install --with-deps chromium,bun run build,bun run test:e2eSim imagesó em main, depois de os três passarem: build, push, envio dos source maps, entrega ao NodusSim -
mainestá protegida nos dois forges: as alterações entram através de pull requests, comcheck,integrationee2ecomo verificações obrigatórias -
Cache: apenas a cache de instalação do Bun, com chave no
bun.lock, guardada só a partir de execuções emmain— um pull request não pode envenenar uma cache em quemainconfia mais tarde
Segurança
Seção intitulada “Segurança”- Sem
pull_request_target. As execuções de pull requests vindos de forks não recebem segredos em nenhum dos forges; manter assim actions/checkoutcompersist-credentials: false, para que os passos seguintes não possam ler o token do disco- Privilégio mínimo:
permissions: contents: readno topo de cada workflow; só o jobimageusa o segredo do registry. O Forgejo ignorapermissionsao nível do job, pelo que, no Forgejo, o isolamento resulta de os segredos só estarem disponíveis emmain - As credenciais do registry vêm de
secrets.REGISTRY_TOKEN, com âmbito de escrita de pacotes; o registry e o nome da imagem vêm devars.REGISTRYevars.IMAGE, para que o mesmo YAML faça push para o GHCR ou para o container registry do Forgejo
Runners Forgejo: uma microVM Firecracker efémera por job
Seção intitulada “Runners Forgejo: uma microVM Firecracker efémera por job”- Cada job Forgejo corre na sua própria microVM Firecracker (Firecracker 1.x, em hosts KVM), que é destruída quando o job termina. Nada sobrevive entre jobs: nem ficheiros, imagens, caches ou credenciais. Equivale ao que acontece nos runners alojados do GitHub, onde cada job também recebe uma VM nova
- Registo efémero: o pool manager regista o runner de cada VM como efémero através da API do Forgejo, pelo que o Forgejo lhe entrega no máximo um job e depois apaga o registo. O runner dentro da VM termina após esse job; o pool manager mata a VM, apaga o seu disco gravável e arranca uma substituta
- Warm pool: o pool manager mantém um pequeno número de VMs já arrancadas e registadas à espera, para que os jobs comecem sem esperar por um boot. O tamanho do pool por host é definido por CPU e memória, não por picos de procura — os jobs ficam em fila quando o pool está ocupado
- Imagem limpa: cada VM arranca a partir de um sistema de ficheiros raiz golden só de leitura (Ubuntu com Docker Engine, git, curl, unzip — de que o instalador do Bun precisa — e bash) mais uma camada gravável nova e vazia. A imagem golden e o kernel do guest são construídos pelo CI, versionados, e reconstruídos semanalmente e perante avisos de segurança. O kernel do guest inclui o que o Docker precisa (overlayfs, cgroups v2, netfilter)
- Dentro da VM, os passos correm diretamente na VM (label
ubuntu-latest:host) com o Docker Engine da própria VM — por isso Testcontainers, E2E e builds de imagens funcionam exatamente como no GitHub. A VM é a fronteira de isolamento; o Docker do host, se existir, nunca é acessível - Rede: cada VM recebe o seu próprio dispositivo tap atrás de NAT. O egress para a internet é permitido (npm, GHCR, data.forgejo.org,
bun.she as releases do GitHub de onde descarrega); os intervalos de endereços privados são bloqueados, exceto a instância Forgejo, o servidor de cache e o mirror do registry. As VMs não conseguem alcançar-se umas às outras, o host, o Nodus nem a produção - Os serviços partilhados vivem fora das VMs: o servidor de cache das Actions e um mirror pull-through do registry (Docker Hub, GHCR) correm na rede do host, para que as VMs efémeras continuem a ter caches Bun quentes e pulls de imagens rápidos. O servidor de cache mantém a regra do Forgejo Runner de que as execuções
pull_requestnão podem escrever caches que outros eventos leem - Os segredos existem apenas dentro da VM de um job e desaparecem com o seu disco. O token de registo é de uso único
A imagem (contrato com o Nodus)
Seção intitulada “A imagem (contrato com o Nodus)”- Uma imagem por commit, etiquetada com o git SHA completo, nunca
latest. As labels OCI registam o repositório de origem e a revisão. A mesma imagem é promovida por todos os ambientes — nunca reconstruída por ambiente - Dois stages, ambos imagens oficiais do Bun na versão de
packageManager, fixadas por digest:- Build em
oven/bun:<version>:bun ci,bun run build(o presetbundo Nitro escreve o servidor em.output/), ebun build --target bunpara os restantes pontos de entrada —instrumentation.ts, o migrator e o worker (ADR-0028) - Runtime em
oven/bun:<version>-distroless: sem shell, sem gestor de pacotes, semcurl. Recebe.output/, os pontos de entrada empacotados e a pasta de migraçõesdrizzle/— nada mais do stage de build. OBun.SQLé integrado (ADR-0008), pelo que nenhum stage precisa de compilador nem de libpq
- Build em
- Definições do stage de runtime:
USER 65532:65532— a imagem distroless do Bun corre como root se não for indicado o contrário. A app não escreve em lado nenhum a não ser/tmp, pelo que o contentor pode correr com um sistema de ficheiros raiz só de leituraNODE_ENV=productionENTRYPOINT ["/usr/local/bin/bun"]eCMD ["--preload", "./instrumentation.js", ".output/server/index.mjs"](ADR-0015); o migrator e o worker são a mesma imagem com umCMDdiferente, que mantém o--preloadHEALTHCHECKem exec form, porque não há shell:bun -ecom umfetcha/healthzque sai com código diferente de zero em caso de falha
- Os source maps são enviados para o GlitchTip pelo job
imagee nunca copiados para o stage de runtime (ADR-0015) - A configuração é feita apenas por variáveis de ambiente, injetadas pelo Nodus em runtime e validadas com Zod no arranque (ADR-0005). Sem segredos na imagem, nos seus build arguments ou nas suas layers
- Saúde:
GET /healthzeGET /readyztal como definidos no ADR-0015; o contrato espera que o Nodus envie tráfego apenas para instâncias cujo/readyzpasse - Graceful shutdown em
SIGTERM: deixar de aceitar ligações, terminar os pedidos em curso, fechar o pool Postgres e o cliente Valkey, fazer flush do SDK OTel e depois sair dentro do período de tolerância do Nodus - As migrações vão na imagem como ponto de entrada separado, usando o migrator de runtime do Drizzle ORM (o Drizzle Kit é uma dependência de desenvolvimento e não está na imagem). O Nodus corre-o uma vez por deploy, antes de a nova versão receber tráfego
- As migrações são retrocompatíveis: durante um deploy a versão antiga e a nova correm lado a lado, pelo que cada migração tem de funcionar com a release anterior — expandir primeiro (adicionar colunas nullable, novas tabelas), contrair numa release posterior (remover o que a versão antiga usava)
Atualizações de dependências
Seção intitulada “Atualizações de dependências”- Renovate, self-hosted, nos dois forges (suporta GitHub e Forgejo; o Dependabot é só GitHub). Atualiza pacotes npm, SHAs de actions e digests de imagens base. Os pacotes que têm de se mover em conjunto são agrupados: o
packageManagercom os digests da imagemoven/bun(ADR-0035), TanStack Query/Router/Start (ADR-0012), os quatro pacotes Better Auth (ADR-0009) e@opentelemetry/*(ADR-0015)
Os fatores decisivos: uma só pipeline, independentemente do forge que aloja o repositório, todos os invariantes de segurança dos ADRs anteriores verificados antes do merge, e um contrato de imagem que permite ao Nodus fazer o deploy de qualquer app da mesma forma. Aceitamos o limite do subconjunto comum nos workflows e ter de construir e operar o pool de runners em microVM.
Consequências
Seção intitulada “Consequências”Mais fácil:
- Mover um repositório entre GitHub e Forgejo não exige alterações à pipeline — apenas variáveis e segredos
- Um teste de guarda (ADR-0014) que falhe bloqueia o merge, não a produção
- Cada artefacto em produção é rastreável a um só commit e idêntico em todos os ambientes; o rollback é voltar a fazer deploy de um SHA anterior
- O Nodus faz o deploy de todas as apps da mesma forma: mesmos endpoints de saúde, mesmo comportamento de shutdown, mesmo ponto de entrada de migrações
- Um job malicioso ou avariado no Forgejo não consegue afetar o seguinte: a sua VM, o disco, o estado do Docker e os segredos desaparecem quando termina
- Os jobs nos dois forges veem o mesmo ambiente — uma VM nova com Docker — pelo que um workflow que passa num passa no outro
Mais difícil:
- O subconjunto comum exclui a maioria das actions do marketplace; os novos passos são shell à volta de
bun, Docker ou uma CLI - O passo do instalador confia no
bun.shem runtime; a versão está fixada, mas o script não. Rever se ooven-sh/setup-bunpassar a estar disponível em data.forgejo.org - Uma imagem distroless não tem shell onde depurar: inspecionar um contentor em execução com
docker debugou um contentor de depuração efémero, nunca trocando a imagem base - O pool de microVMs é nosso para construir e operar: hosts KVM (bare metal ou virtualização aninhada), o pool manager, a imagem golden e o kernel do guest, o servidor de cache, o mirror do registry e a capacidade
- Cada job Forgejo arranca a frio: Bun, dependências e browsers do Playwright instalam-se de cada vez, atenuado apenas pela cache externa e pelo mirror do registry. Medir os tempos dos jobs; incluir na imagem golden as peças lentas e estáveis entre versões só quando não variam por projeto
- As migrações expand/contract demoram duas releases em qualquer alteração destrutiva de schema
- O lado do Nodus no contrato (trigger, ambientes, promoção, rollback, injeção de segredos) não está registado aqui; tem de corresponder a estes pressupostos
Do que abdicamos: do setup-bun e do marketplace do GitHub em repositórios alojados no GitHub, de funcionalidades de pipeline específicas de cada forge, e de construir por ambiente. Aceite — uma só pipeline nos dois forges e um só artefacto por commit pesam mais do que a conveniência em qualquer um deles.

