Pular para o conteúdo

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

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 em uses: resolvem contra https://data.forgejo.org (um espelho de actions comuns), não contra o GitHub. permissions e continue-on-error ao nível do job são ignorados, e o OIDC usa enable-openid-connect em vez de permissions: id-token: write
  • O GitHub só aceita owner/repo@ref em uses: — 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-latest alojado 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.)

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.

Prós: sem trabalho de compatibilidade. Contras: não corresponde ao sítio onde os nossos repositórios realmente vivem.

  • oven-sh/setup-bun — a action oficial, com cache, e lê o packageManager; mas é uma action de terceiros, pelo que no Forgejo só resolve se o data.forgejo.org a espelhar
  • oven/bun como 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

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

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.

  • 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), contextos github.* (o Forgejo fornece-os) e actions/checkout como única action. Tudo o resto é um passo run: que chama bun

  • 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/checkout v7.0.1)

  • Bun instalado pelo seu instalador oficial na versão exata do campo packageManager do package.json da raiz (ADR-0035), e depois bun ci, que falha se o bun.lock estiver desatualizado

  • Triggers: pull_request e push para main. concurrency por ref, cancelando as execuções ultrapassadas apenas em pull requests

  • Jobs:

    Job Corre (depois de bun ci) Precisa de Docker
    check bun run check, bun run test Não
    integration bun run test:int Sim
    e2e bunx playwright install --with-deps chromium, bun run build, bun run test:e2e Sim
    image só em main, depois de os três passarem: build, push, envio dos source maps, entrega ao Nodus Sim
  • main está protegida nos dois forges: as alterações entram através de pull requests, com check, integration e e2e como 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 em main — um pull request não pode envenenar uma cache em que main confia mais tarde

  • Sem pull_request_target. As execuções de pull requests vindos de forks não recebem segredos em nenhum dos forges; manter assim
  • actions/checkout com persist-credentials: false, para que os passos seguintes não possam ler o token do disco
  • Privilégio mínimo: permissions: contents: read no topo de cada workflow; só o job image usa o segredo do registry. O Forgejo ignora permissions ao nível do job, pelo que, no Forgejo, o isolamento resulta de os segredos só estarem disponíveis em main
  • 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 de vars.REGISTRY e vars.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.sh e 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_request nã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
  • 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 preset bun do Nitro escreve o servidor em .output/), e bun build --target bun para 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, sem curl. Recebe .output/, os pontos de entrada empacotados e a pasta de migrações drizzle/ — nada mais do stage de build. O Bun.SQL é integrado (ADR-0008), pelo que nenhum stage precisa de compilador nem de libpq
  • 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 leitura
    • NODE_ENV=production
    • ENTRYPOINT ["/usr/local/bin/bun"] e CMD ["--preload", "./instrumentation.js", ".output/server/index.mjs"] (ADR-0015); o migrator e o worker são a mesma imagem com um CMD diferente, que mantém o --preload
    • HEALTHCHECK em exec form, porque não há shell: bun -e com um fetch a /healthz que sai com código diferente de zero em caso de falha
  • Os source maps são enviados para o GlitchTip pelo job image e 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 /healthz e GET /readyz tal como definidos no ADR-0015; o contrato espera que o Nodus envie tráfego apenas para instâncias cujo /readyz passe
  • 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)
  • 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 packageManager com os digests da imagem oven/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.

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.sh em runtime; a versão está fixada, mas o script não. Rever se o oven-sh/setup-bun passar 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 debug ou 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.