Pular para conteúdo

CI, gates e testes

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-10-01

Aplica-se a: perfis app e lib, toda stack; o repo de config tem o gate próprio em PowerSync.

Como o código é verificado no CI e que testes provam que não quebrou. O piso está na definição de pronto; aqui está o detalhe.

Branch principal

O branch de todo repositório é master. O pipeline.yml dispara em push nele (o template já vem com branches: [master]) e em tag v*; repo em main fica sem CI, em silêncio.

CI de código — um pipeline.yml, jobs com papéis distintos

O CI/CD é um pipeline.yml único no GitHub Actions (0027). O job decide liga os demais conforme o evento:

  • Push ou PR: por path — código liga gate; docs/** liga docs. Push de doc só (sem código) em master publica o dev/ e republica a versão vigente, sem build nem deploy.
  • Tag vX.Y.Z: release. O decide lê o trailer Deploy: <alvos> da tag anotada e liga só esses builds; sem trailer, o fallback é o build.targets do docs/app.json — nunca "sem binário". O decide faz checkout com fetch-depth: 0 para o corpo da tag chegar ao CI. Mudança só de um alvo (só o Dockerfile.native) é release, com o trailer daquele alvo.
  • Commit chore(release) em master não roda o CI do branch: o if: do próprio decide pula o job, e os que dependem dele saem pulados, sem alocar runner nem cobrar minuto; a tag empurrada junto roda o release. O concurrency também não cancela run de tag, porque release é imutável.
  • workflow_dispatch: checkboxes por job; dispatch sem testes não deploya.
flowchart TB
    E["push · PR · tag vX.Y.Z · workflow_dispatch"] --> D["decide<br/>liga os jobs pelo evento e pelo path"]
    D -- "código mudou" --> G["gate<br/>estática + testes + guardas<br/>+ contrato de release, na tag"]
    D -- "docs/ mudou" --> DOC["docs<br/>valida, builda e publica o site"]
    G -- "na tag, só verde" --> DOC
    D -- "tag: trailer Deploy:<br/>fallback build.targets" --> B["build_jar · build_native · build_web<br/>fora do host de produção"]
    G --> DP{"deploy<br/>só com gate verde<br/>e, na tag, docs verdes"}
    DOC -- "na tag" --> DP
    B --> DP
    DP -- "POST /api/ci/deploy" --> S["smoke, último step do deploy<br/>identidade, rotas e erro novo"]
    S -- "reprova" --> RB["reverter pela tag imutável"]

Os jobs:

  • gate — análise estática + testes da stack + as guardas da casa. Lint e estilo que só disparam no check/analyze falham o PR, não escapam para o master:

    • Java/Gradle → ./gradlew check (setup-java@v5 + setup-gradle@v4; Testcontainers no docker nativo do runner);
    • Flutter → dart format --output=none --set-exit-if-changed . && flutter analyze && flutter test --exclude-tags golden --coverage (subosito/flutter-action@v2; golden fica fora do gate — Flutter);
    • Dart → dart format --output=none --set-exit-if-changed . && dart analyze && dart test;
    • Node → npm run lint && npm test.

    Na tag, o último step do gate é o contrato de release: valida-release.py (SemVer ↔ CHANGELOG ↔ tag). Ele roda mesmo com teste vermelho, para os dois diagnósticos saírem no mesmo run, e o gate vermelho por ele barra o docs e o deploy da tag. - docs — valida e publica o site do app. Espera o gate; no push de tag, só roda com ele verde, porque doc de release que não vai ao ar não se publica. Fora da tag (PR, master, docs-only, workflow_dispatch), roda com o gate em qualquer estado, como antes. Os steps, nesta ordem: 1. Validar frontmatter — valida-frontmatter.py docs/ (frontmatter, app.json, massa de dados); 2. Baixar hooks de doc — frontmatter-cabecalho.py (a data do cabeçalho vem do atualizado:, porque o .git não entra no build) e visao-tecnica.py; 3. Lint dos diagramas Mermaid — lint-mermaid.mjs docs/ (npm install --no-save mermaid@11 jsdom); 4. (opt-in, app dono de contrato REST) gerar o OpenAPI para docs/public/api/ e rodar o checa-rotas.py; 5. (opt-in, consumidor de fato de outro repo) importar os fatos para docs/_importado/; 6. Buildar e validar — mkdocs build --strict, checa-admonition.py e a guarda de privado/ no site/; roda sempre, inclusive em PR; 7. Publicar no Garage, avisar o central e, em push de doc só, republicar a versão vigente; em release, atualizar o versions.json — só com alvo, nunca em PR. Na tag, a falha do Garage vira aviso, para que rede instável não barre o deploy. O aviso ao central e o versions.json são pulados, e o portal nunca aponta para versão sem site. O ::warning:: diz como republicar: workflow_dispatch na tag, com docs marcado e tests desmarcado. - build_jar/build_native/build_web — builda e publica a imagem de cada alvo ligado, num runner fora do host de produção. - deploy — dispara o control-plane, só com o gate verde e, na tag, o docs verde, um step por alvo cujo build passou. O docs só conta quando foi ligado: o workflow_dispatch sem docs não espera por ele. A resposta do curl -f vai para uma variável antes do echo: com bash -e, echo "$(curl -f …)" engole a falha e o step sai verde sem ter deployado. O último step é o smoke, que verifica produção depois do POST (Smoke de produção).

O contrato de release e o smoke são steps, não jobs próprios: o GitHub cobra cada job arredondado para cima ao minuto, e os dois duram segundos. O contrato fica no gate, que roda em toda tag, e não no deploy, que não roda em repo sem alvo de build.

O pré-flight da /xadm-release roda na máquina do dev o mesmo gate e os steps de validação do docs antes de taggar. Máquina sem Docker roda o gate Java degradado (checkstyleMain checkstyleTest), e o relatório da release declara isso.

Guarda inline do pipeline.yml nasce com caso de teste

O job gate carrega guardas escritas como bloco python3 - <<'PY' dentro do step (placeholder do Micronaut, piso do ArchUnit, paridade dos Dockerfiles, DDL destrutivo, @Scheduled sem eleição…). Elas rodam na frota inteira, em todo push: são código de produção do central.

Guarda nova ou mexida entra no scripts/testa-guardas-pipeline.py do central com pelo menos um caso verde, um vermelho e um mutante. O harness extrai o bloco do próprio templates/pipeline.yml pelo name do step, roda cada caso numa árvore temporária e aplica as mutações — mutante que sobrevive é falha, porque a linha mutada não era exercida por nenhum caso. Uma meta-guarda reprova step de guarda do gate sem entrada no harness. É gate do central: o app recebe as guardas já testadas ao re-derivar o pipeline.yml.

A estrutura entre jobs também é código da frota: needs, if: e o outcome de um step tolerado. O harness cobra essas peças por asserção textual, cada uma com o seu mutante. O central roda ainda o actionlint (binário pinado com sha256) nos templates e no próprio build-site.yml. Ele confere o que só o Actions avalia: o id do step que uma expressão lê, a sintaxe da expressão e o job do needs.

As guardas ignoram build/, bin/, out/, target/, .gradle e node_modules: o dev as roda na máquina dele, e uma cópia velha de artefato reprovaria. Rodada no Windows, a guarda que chama o Gradle usa cmd /c .\gradlew.bat — caminho explícito, porque com NoDefaultCurrentDirectoryInExePath=1 o cmd não busca na pasta atual — e normaliza a \ que o glob devolve.

Gate cuja lista é datada por versão de terceiro prova o vermelho a cada bump

Gate que codifica o que a versão X de um terceiro rejeita — lint de sync rule contra a tag do PowerSync, guarda que procura uma string de erro — fica verde para tudo quando a versão sobe e a construção proibida passa a ser aceita. Ao subir o pin de um terceiro, rode o gate contra uma entrada sabidamente inválida e confirme que ele reprova; não reprovou, a regra se reescreve contra o comportamento novo ou sai. Receita em PowerSync.

Verde declarado por agente vem do artefato, não do exit code

No CI o step roda o comando puro sob bash -e, e o runner reprova sozinho. Fora dele, quem declara verde lendo a saída (o agente no pré-flight, o dev antes de taggar) depende de um exit code que atravessou invólucro, shim e shell, e cada camada pode reescrevê-lo. Para declarar "os testes passaram", confira os build/test-results/**/*.xml — tests/failures/errors agregados e o mtime de todos: XML antigo é task pulada pelo up-to-date do Gradle, e zero XML é "não rodou".

Chave YAML repetida — a que o --strict nunca vai ver

YAML não avisa sobre chave duplicada num mapping: a última sobrescreve a primeira, e para o MkDocs a apagada nunca existiu. Um segundo exclude_docs: apaga a exclusão de privado/ com todos os gates verdes. O checa-nav.py reprova qualquer chave declarada duas vezes no mesmo mapping do mkdocs.yml; a cura é fundir os blocos. Ele também reprova .md fora do nav:.

Contrato REST — a prosa confere com o OpenAPI?

O checa-rotas.py confere cada rota REST citada na prosa contra o OpenAPI gerado dos @Controller — direção prosa → spec: o spec sai do código e é a verdade. Roda no gate (depois do check, que é quando o spec existe; pega o rename no PR de código) e no docs (pega a prosa editada sem tocar código, inclusive pela edição web do Forgejo).

curl -fsSL https://docs.xadm.biz/toolchain/checa-rotas.py -o checa-rotas.py
python3 checa-rotas.py

Não vira vermelho, por desenho:

Situação Por quê
Rota cujo primeiro segmento não existe no spec API de parceiro que o app só consome, rota de app vizinho
Rota que é prefixo por segmento de uma do spec diagrama que nomeia a família da rota
MÉTODO /prefixo/… (ou /...) com ao menos uma rota do método abaixo do prefixo a prosa resume sub-rotas; sem nenhuma rota do método ali, reprova
Nome do path param ({id} × {processamentoId}) livre dos dois lados
Documento status: obsoleto já se declara não-atual
Linha com <!-- checa-rotas: ignorar --> escape explícito, de linha (decisão que registra rota removida)

O oráculo é só o OpenAPI gerado deste app: spec escrito à mão de outro sistema (o do parceiro em docs/public/api/) não se passa ao checa-rotas.

Rota, não forma: o spec gerado é oráculo fiel de {método, path}, e frágil para corpo (required, enum, nullable — validação imperativa e serialização custom escapam dele). A forma se atesta no teste de fio, contra o runtime. Ponto cego: renomear o primeiro segmento inteiro (/api/* → /v2/*) faz a prosa velha passar como rota de terceiro.

Teste OS-desabilitado é gate cego no pré-flight

O pré-flight roda no Windows; a CI, em Linux. Teste @DisabledOnOs(OS.WINDOWS) (ou @EnabledOnOs(OS.LINUX)) é pulado no check local e deixa o gate verde, sem sinal de degradado — a divergência só aparece na CI, depois da tag. Em ordem de preferência:

  1. Não desabilitar por OS. Testcontainers roda no Docker Desktop; reserve o disable a teste genuinamente Linux-only.
  2. Rodar o gate em paridade Linux antes de taggar: docker run --rm -v "<repo>":/w -w /w eclipse-temurin:25-jdk ./gradlew check (o gradlew em LF pelo .gitattributes; com Testcontainers, monte o socket do Docker).

O pré-flight da /xadm-release procura essas anotações e declara no relatório os testes que não rodaram.

Todo curl do pipeline tem timeout

A zona *.xadm.biz tem dois registros A (0039). Com um dos IPs fora do ar, o curl sem timeout fica pendurado no connect desse IP por minutos antes de tentar o outro, e o job morre no timeout-minutes. Por isso todo curl do pipeline.yml e do pipeline-config.yml leva --connect-timeout 10 --max-time 60: com o IP morto, ele passa ao outro em segundos (upload de arquivo grande leva um --max-time maior). O testa-guardas-pipeline.py reprova curl sem os dois nos templates. Token lido do secret tem guarda de vazio no próprio step que o usa: o deploy falha com o nome do secret, e o aviso ao central vira ::warning::, em vez de um 401 sem dono.

Caminho que só roda na tag

Build de imagem, publish de lib e deploy só rodam na tag; o gate e o pré-flight não passam por eles. Quem muda o pipeline muda código que roda pela primeira vez no release, quando a tag já está no ar. Foi o que aconteceu no xadm-commons: o env do step de publish foi renomeado, o build.gradle.kts seguiu lendo o nome antigo, e todos os gates passaram até o job da tag falhar sem credencial. A guarda ${VAR:?} do step não pegou porque conferia a variável que o próprio step exporta, não a que o build lê.

Mudança no pipeline não passa calada até a tag. O filtro code do pipeline.yml inclui .github/workflows/**, então o push que mexe no pipeline roda o gate. A /xadm-release detecta pipeline mudado desde a última tag, confere com o dev que cada nome que o build lê do ambiente ainda é exportado pelo step que o chama, e avisa no relatório que o caminho de release roda pela primeira vez. Guarda de step confere o que o consumidor lê, nunca só o que o próprio step exporta.

Não há ensaio do build por workflow_dispatch antes da tag: o dispatch sem testes publica a tag móvel <alvo>-amd64, a que o Coolify puxa, e um redeploy manual subiria código fora de release.

Runner: GitHub Actions (ubuntu-latest)

  • Java: actions/setup-java@v5 (temurin) + gradle/actions/setup-gradle@v4; Testcontainers no docker nativo do runner.
  • Flutter: subosito/flutter-action@v2 (cache: true); native assets (PowerSync/sqlite3) precisam de ninja-build.
  • A versão vem do docs/app.json toolchain: o decide a exporta e o gate e os builds a usam; uma guarda compara o .fvmrc e o FROM dos Dockerfiles com ela.
  • Asset gerado no build (ex. dart run powersync:setup_web): o gate gera antes do teste, senão falha em checkout limpo.
  • App só-script (um .mjs sem suíte): análise estática sempre; teste da lógica pura onde existir.

A higiene de saída de comando (reporter frugal, comando bare) está em Agentes; o rtk é recomendado, opt-in de máquina (Ferramentas).

Definição de pronto — o detalhe (testes)

Nenhuma regressão sem teste de regressão, e a verificação exercita a plataforma real (web, mobile, o build do app), não só --strict e fixture. Por nível:

  • Unitário — lógica pura (parsers, conversores, regras, limites).
  • Integração — repositórios, storage, contratos externos (mock HTTP, containers). Roda dentro do check, que roda na CI. Integração atrás de flag que a CI não passa (@Tag("docker") excluído, reativado só com -PdockerTests) é cobertura órfã e o pipeline.yml a reprova: ou roda no check, ou — se precisa de serviço externo real — vira suíte e2e-local nomeada.
  • E2E — obrigatório em fronteira de segurança ou de contrato (auth, endpoint público, troca de formato, view server-render, fluxo de integração multi-app); opcional no resto. Servidor que o teste sobe usa porta efêmera e lê a URL do próprio servidor (Agentes). Fluxo multi-app se prova numa suíte e2e-local.

As regras que valem em todo nível:

  • Auth e serviço nosso rodam de verdade, local e automatizados. O login do central-backend sobe no e2e-local como qualquer app. Serviço externo que não roda local entra por emulador oficial ou, quando o emulador não assina (validador exige RS256 e o emulador emite alg:none), por JWKS próprio apontado por seam de config (e2e-local).
  • Endereço de serviço externo é config, nunca constante: base URL, endpoint e JWKS nascem em property/--dart-define com o default de produção — constante impede o teste de apontar ao stub.
  • Ambiente deployado + clique de operador é smoke de deploy, não nível de teste. O tier staging/ de etc/tests é e2e local sobre dump de produção anonimizado, com gate de migração; não é ambiente de staging.
  • Escreva no nível mais baixo — e2e é o mais caro. Audite os testes que já existem; escreva no menor nível que prova (unit antes de integração antes de e2e), e o e2e cobre só o fio que não fecha abaixo (cross-process, UI → backend → banco, runtime native). Cada suíte e2e-local carrega um COBERTURA.md (gap → nível → onde fecha).
  • E2E como única prova de um componente é gap, não cobertura. Classe com I/O próprio que só executa quando a suíte sobe o docker está descoberta; o teste certo é o fake do protocolo em porta efêmera na própria JVM (Java/Micronaut).
  • Código que roda por entrypoint sem request (@Scheduled, evento, thread própria, boot, CLI) se testa disparando o entrypoint real, não chamando o método sob o contexto emprestado do teste — que passa verde e estoura em produção.
  • Cobertura ~0% numa classe que um teste verde deveria atravessar é sintoma: um fake substituiu o bean real, o servidor embutido chamou outra implementação, ou o .exec é de outra rodada. Investigue antes de escrever teste novo.
  • Em componente compartilhado a régua sobe: o defeito numa lib vira N incidentes e às vezes não tem contorno no adotante. Numa lib, o descoberto em segredo, auth, controle de abuso e limites (janela, reset, precedência de header) é o que mais importa cobrir, e cobertura creditada ao e2e não fecha a conta.
  • Comportamento que depende de tempo nasce com relógio injetável — seam de produção com default real; o teste avança o tempo (Java: Clock; Flutter: o _now injetável).
  • Paridade em refactor: refactor sem mudança externa mantém o gate verde e confere a saída contra a referência quando há uma. Caracterização precede refactor: mesmo nome ≠ mesmo corpo — antes de "unificar N iguais", leia os corpos.
  • Deferir teste é custo × valor, não racionalização: só no quadrante caro e de baixo valor, e declarado (REGRA Nº 3); comportamento sensível (segurança, privacidade, contrato) ganha a guarda agora.
  • O teste não reimplementa a lógica que atesta. Teste que refaz no corpo a conta da produção concorda com o bug. O esperado vem de fora: valor literal escrito à mão, saída capturada e conferida, ou invariante independente. Sintoma: laço, sum/fold ou if no teste espelhando o código. Detecção: teste de mutação (PIT) sob demanda, ou à mão — quebre a linha e veja o teste ficar vermelho.
  • Fixture de API externa é capturada da instância real, não inventada; provider externo só é "ligado" com ao menos uma resposta real capturada.
  • Contrato de entrada entre componentes nossos é travado nos dois lados: o provedor tem teste que rejeita payload sem os campos obrigatórios (400 legível, nunca 500), e o cliente declara os mesmos campos.
  • Fidelidade ao artefato empacotado: teste roda no classpath, o deploy sobe o jar ou o binário; ordem, descoberta e config têm de ser determinísticas nos dois. Onde a divergência é inevitável, o gate sobe o artefato empacotado.
  • Cobertura é visível, não bloqueante: o gate imprime o %, e o número publicado é o badge do README.md e do site, em docs/assets/badges/; piso bloqueante é opt-in por app, em decisão. A coleta roda com re-exec forçado (--rerun-tasks), e o gerado sai do denominador (Java/Micronaut); Flutter usa flutter test --coverage. O badge sai do jar (o native não é instrumentável) na rodada e2e local com cobertura, e são dois: cobertura unit+int, o número da casa, e cobertura +e2e, o mesmo report com o e2e mesclado — a diferença é o que só o e2e prova, o gap da regra acima; a lib publica só o primeiro. É gerado e versionado (exceção): só rodada verde e limpa, com o HEAD no sha medido, escreve; o SVG não leva data (o sha vai no <title>), e nem ele nem o bloco <!-- xadm:badges --> do README.md e do docs/index.md se editam à mão. A /xadm-release o leva no commit do release.
  • Verificador não muta a árvore: formatador em modo de checagem (dart format --output=none --set-exit-if-changed, spotlessCheck, ktlintCheck). Se o gate mudou arquivo, o gate estava errado.
  • Doc do estado atual inclui o screenshot: mudou a UI retratada no manual, regenere o PNG e abra a imagem para conferir o elemento novo (Screenshots do manual).
  • Detector automático de "a doc seguiu o código" só existe onde a prosa tem um artefato gerado para conferir contra — o checa-rotas.py. No resto, quem cobra é a revisão do PR.