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/**ligadocs. Push de doc só (sem código) emmasterpublica odev/e republica a versão vigente, sem build nem deploy. - Tag
vX.Y.Z: release. Odecidelê o trailerDeploy: <alvos>da tag anotada e liga só esses builds; sem trailer, o fallback é obuild.targetsdodocs/app.json— nunca "sem binário". Odecidefaz checkout comfetch-depth: 0para o corpo da tag chegar ao CI. Mudança só de um alvo (só oDockerfile.native) é release, com o trailer daquele alvo. - Commit
chore(release)emmasternão roda o CI do branch: oif:do própriodecidepula o job, e os que dependem dele saem pulados, sem alocar runner nem cobrar minuto; a tag empurrada junto roda o release. Oconcurrencytambé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 nocheck/analyzefalham o PR, não escapam para omaster:- 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 odocse odeployda tag. -docs— valida e publica o site do app. Espera ogate; 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 doatualizado:, porque o.gitnão entra no build) evisao-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 paradocs/public/api/e rodar ocheca-rotas.py; 5. (opt-in, consumidor de fato de outro repo) importar os fatos paradocs/_importado/; 6. Buildar e validar —mkdocs build --strict,checa-admonition.pye a guarda deprivado/nosite/; 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 oversions.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 oversions.jsonsão pulados, e o portal nunca aponta para versão sem site. O::warning::diz como republicar:workflow_dispatchna tag, comdocsmarcado etestsdesmarcado. -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 ogateverde e, na tag, odocsverde, um step por alvo cujo build passou. Odocssó conta quando foi ligado: oworkflow_dispatchsem docs não espera por ele. A resposta docurl -fvai para uma variável antes doecho: combash -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 doPOST(Smoke de produção). - Java/Gradle →
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:
- Não desabilitar por OS. Testcontainers roda no Docker Desktop; reserve o disable a teste genuinamente Linux-only.
- Rodar o gate em paridade Linux antes de taggar:
docker run --rm -v "<repo>":/w -w /w eclipse-temurin:25-jdk ./gradlew check(ogradlewem 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 deninja-build. - A versão vem do
docs/app.jsontoolchain: odecidea exporta e o gate e os builds a usam; uma guarda compara o.fvmrce oFROMdos 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
.mjssem 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 opipeline.ymla reprova: ou roda nocheck, 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-backendsobe 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 emitealg: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-definecom 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/deetc/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_nowinjetá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/foldouifno 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.mde do site, emdocs/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 usaflutter 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, ecobertura +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 -->doREADME.mde dodocs/index.mdse editam à mão. A/xadm-releaseo 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.