CI, gates e testes¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-10
Como o código é verificado no CI e que testes provam que não quebrou. O piso da definição de pronto (teste proporcional ao risco + doc do estado atual, no mesmo PR) é disciplina de fechamento e fica na constituição §6; aqui está o detalhe de engenharia.
Branch principal¶
O branch padrão de todo repositório é master. Os workflows de CI (docs.yml,
ci.yml) disparam em push nesse branch — o template já vem com branches: [master],
sem ajuste por repo. Esquecer isso num repo em main (ou vice-versa) faz a CI nunca
rodar em silêncio (só a release, que é por tag v*, independe de branch). Não
renomear para main.
CI de código — dois workflows com papéis distintos¶
Não confundir — o mesmo arquivo com dois propósitos colide.
ci.yml= gate de qualidade (push/PR no branch padrão): roda o conjunto análise estática + testes da stack, não só os testes — lint/estilo que só dispara emcheck/build(ex. checkstyle,flutter analyze) precisa falhar o PR, não escapar para o master. Por stack:- Java/Gradle →
./gradlew check - Flutter →
dart format --output=none --set-exit-if-changed . && flutter analyze && flutter test - Dart →
dart format --output=none --set-exit-if-changed . && dart analyze && dart test - Node →
npm run lint && npm test - docs (MkDocs) →
mkdocs build --strict+valida-frontmatter.py docs/+gera-manifesto.py --check+node scripts/lint-mermaid.mjs docs/(parse headless dos diagramas — o--strictnão parseia Mermaid; um;numa mensagem desequenceDiagramjá quebra o build e trava o deploy sem aparecer no gate local; também reprovaflowchart LR/RLgrande, que a constituição §5 pede emTB). Espelhe TODO esse conjunto localmente antes do push (o/x-implementare o pré-flight do/xadm-releaserodam o lint-mermaid; precisanpm i --no-save mermaid@11 jsdom).
- Java/Gradle →
release.yml= rede de segurança do release (push de tag): roda ovalida-release.py(SemVer no manifesto ↔ CHANGELOG ↔ tag), independente da skill/xadm-release.
Além do gate da stack, o ci.yml roda os checks de doc que o gate de código enxerga —
todos stdlib, baixados frescos do site, tolerantes (sem o que checar, saem 0):
checa-nav.py (.md fora do nav:, e chave YAML repetida no mkdocs.yml — abaixo),
checa-views.py (shape dos fragments Thymeleaf) e checa-rotas.py (contrato REST — abaixo).
Chave YAML repetida — a que o --strict nunca vai ver¶
YAML não avisa sobre chave duplicada num mapping: a última sobrescreve a primeira, sem
erro nem warning. Para o MkDocs a chave apagada nunca existiu — então o --strict fica
verde por estar certo, não por descuido: não há nada de errado no config que ele recebeu. É
a mesma classe do Mermaid que não parseia (motivo do lint-mermaid) e do admonition cru
(motivo do checa-admonition): defeito que só existe entre o fonte e o que a ferramenta
leu. Caso real (0.24.2): o template canônico ganhou um segundo exclude_docs: 48 linhas
abaixo do primeiro; o bloco de cima — que excluía privado/, segmento reservado a
confidencial de cliente (§5) — foi descartado calado, e todo app que re-derivasse o
template passaria a publicar docs/privado/ no site.
O checa-nav.py reprova a classe, não o sintoma: qualquer chave declarada 2× no mesmo
mapping (dois nav:, dois plugins:, dois theme:) falha igual. Cura: fundir os blocos
num só — nunca abrir um segundo. O que o gate não pega: chave certa com valor errado,
e duplicata que o YAML resolve legitimamente (dois - name: em itens de lista distintos
são mappings distintos — o check sabe disso e não os acusa).
A guarda de privado/ no docs.yml (procura site/**/privado/ depois do build) continua
sendo a rede de baixo, mas é só do sintoma daquela vez: pega depois do build, e só nos
apps que a têm.
Contrato REST — a prosa confere com o OpenAPI?¶
A §6 manda a doc mudar no mesmo PR que muda o contrato, e por muito tempo nada detectava
quando isso não acontecia. Caso real: uma rota de upload foi renomeada no controller; o
OpenAPI, que o micronaut-openapi deriva dos @Controller/@Post, acompanhou sozinho;
a prosa não. mkdocs build --strict, checa-nav e o ci.yml ficaram todos verdes por 10
dias, com o site publicado servindo o contrato novo e a prosa velha lado a lado — nenhum
deles jamais afirmou nada sobre a relação entre os dois.
O checa-rotas.py fecha essa classe. Direção prosa → spec: o spec é a verdade (sai do
código), a prosa é a afirmação a conferir. O contrário — cobrar que toda rota do spec apareça
na prosa — seria cobrança de cobertura de doc, ruidosa e contra a §1.2.
curl -fsSL https://docs.xadm.biz/padroes/checa-rotas.py -o checa-rotas.py
python3 checa-rotas.py # DEPOIS do build: o spec nasce ao compilar
python3 checa-rotas.py caminho/spec.yml
Roda nos dois workflows, e não é redundância: no ci.yml (depois do ./gradlew check,
que é quando o spec existe) ele pega o rename no PR de código — é lá que tem que doer; no
docs.yml pega o caminho inverso, prosa editada sem tocar código, inclusive pela interface
web do Forgejo, que não dispara o ci.yml.
O que não vira vermelho, por desenho:
| Situação | Por quê |
|---|---|
| Rota cujo 1º segmento não existe no spec | API de parceiro que o app só consome (POST /sync/erp), rota de app vizinho documentado no mesmo repo (POST /debug/...) |
| Rota que é prefixo por segmento de uma do spec | Diagrama de topologia nomeia a família (/xadm/retorno por /retorno/{tabela}) |
Nome do path param ({id} × {processamentoId}) |
Livre dos dois lados |
Documento com status: obsoleto |
Já se declara não-atual |
Linha com <!-- checa-rotas: ignorar --> |
Escape explícito, escopo de linha |
O escape existe para a prosa que precisa nomear uma rota que deixou de existir: uma decisão de remoção não tem como dizer "removemos X" sem escrever X. Decisão ativa que cita rota morta não é escapada — é drift, e o leitor do site tenta usar a rota.
Ponto cego declarado: renomear o primeiro segmento inteiro do app (/api/* → /v2/*)
faz a prosa velha virar "rota de terceiro" aos olhos do check e passar.
Rota, não forma — e não é limitação a fechar, é a linha onde o spec deixa de ser
oráculo. O check confere {método, path}; nada sobre corpo — required, enum,
nullable, tipo de campo. A tentação óbvia é estendê-lo a components/schemas; a razão de
não fazer é a mesma que faz o check de rota funcionar. O micronaut-openapi extrai
path e verbo das annotations @Get/@Post fielmente — para rota, o spec gerado é
oráculo confiável. Para forma, o mesmo spec é frágil: só enxerga o que está em
@Schema/@Nullable/validação declarativa, e escapa dele toda validação imperativa (um
if no service), default e serialização custom. Caso real de app: o schema do OpenAPI
divergia do runtime (required/enum/nullability), e os dois gates ficaram verdes —
quem pegou foi o teste de fio, não a toolchain.
Um check de schema-drift prosa × spec não resolveria isso, pioraria: se a prosa concorda com o spec errado, passa verde com ambos mentindo (é o teste tautológico por outro caminho — o oráculo saiu da mesma fonte que o alvo); se a prosa acerta o runtime e o spec erra, o check acusa a prosa correta. Verde passaria a significar "prosa e spec concordam", não "a forma está certa". O único oráculo independente de forma é o runtime — o teste de fio (§6: E2E obrigatório em fronteira de contrato, exercita o runtime real, oráculo vindo de fora). É lá que a forma se atesta, e é decisão deliberada não movê-la para um gate estático que daria falso conforto. Mesma linha dos gates de kit de UI: o central garante shape, não render; aqui, a toolchain garante que a prosa não contradiz o spec na rota, não que o spec reflete o runtime.
O pré-flight do /xadm-release roda o conjunto acima — o da stack, não só o de docs —
antes de taggar. Vale para o gate de código: checkstyle e flutter analyze só disparam
dentro do check/analyze, escapam da análise estática que se faz lendo o diff, e um vermelho
descoberto depois da tag já pushada trava o deploy (que é por push) e força uma release de
correção. Máquina sem Docker/Testcontainers roda o gate degradado (Java: checkstyleMain
checkstyleTest) e o release declara isso no relatório final — meio gate é decisão explícita,
não silêncio.
Templates rastreados: ci.yml, release.yml.
Teste OS-desabilitado é gate cego no pré-flight¶
O pré-flight roda na máquina do dev (Windows); a CI roda em Linux (ci-<stack>). Um
teste anotado @DisabledOnOs(OS.WINDOWS) (ou @EnabledOnOs(OS.LINUX)) é pulado no check
local — e o check fica verde, sem nem o sinal de degradado que a falta de Docker dá. A
divergência que só esse teste pega (helper × produção, separador de path, locale) fica
invisível até a CI, que roda depois do push/da tag: verde local, vermelho CI, tag já no
ar, release de correção — o exato modo de falha que o pré-flight existe pra evitar. (Caso real de
app: @DisabledOnOs(OS.WINDOWS) num teste de integração; check verde no Windows, tag v0.0.5
criada, CI vermelha logo depois.) Duas saídas, em ordem de preferência:
- Não desabilitar por OS. Teste do qual você depende deve rodar cross-OS. Testcontainers roda
no Docker Desktop do Windows — a maioria dos motivos de
@DisabledOnOssome. Reserve o disable a teste genuinamente Linux-only (unix socket, permissão de arquivo, scriptsh). - Rodar o gate em paridade Linux antes de taggar:
docker run --rm -v "<repo>":/w -w /w ci-java:<v> ./gradlew check— o mesmo ambiente da CI, então o teste OS-desabilitado executa. Ogradlewprecisa de fim-de-linha LF no checkout Windows (.gitattributescomgradlew text eol=lf); se o teste usa Testcontainers, monte o socket do Docker (mesma topologia dind da CI — o runner injetaDOCKER_HOST).
O pré-flight da /xadm-release procura essas anotações nos testes e, se acha, avisa no
relatório que elas não rodaram localmente (degradado declarado, não silêncio). O gate de
CI-verde é aviso, não bloqueio: o Coolify auto-deploya no push e a CI só valida/avisa no
Telegram, então o pré-flight lembra de confirmar a run verde (que roda pós-push/pós-tag) antes de
dar o release por sólido — gatear o deploy pela CI é melhoria futura (o deploy do push já
aconteceu quando a CI fecha).
Runner: imagens de CI por stack + cache¶
O runner Forgejo da org é efêmero (container novo a cada run) e não resolve
actions de marketplace (data.forgejo.org → Not found) — só actions/*
(checkout, cache, setup-*). Por isso o ambiente vem pronto na imagem, não
montado por run:
- Imagens de CI por stack+versão (fábrica — decisão 0003):
fonte.xadm.biz/xadm/ci-base(docs/Node),ci-java:<v>(JDK + dist do Gradle baked — o./gradlewem cache frio não baixa, adendo 0003) eci-flutter:<v>(SDK Flutter baked — mata ogit clone, que era o maior custo do CI Flutter). Ocontainer:doci.yml/docs.ymlaponta a da stack, na versão declarada noapp.json(toolchain.*). App Java/Flutter é OBRIGADO a declarartoolchain.{java|flutter}(o validador falha sem ele) — é o que a fábrica usa para buildar a imagem-versão; sem a declaração ocontainer:aponta para uma imagem inexistente. A matriz é montada automaticamente das declarações + baseline; não traz mkdocs (pins com fonte única em publicar-docs). - Cache do Gradle (
actions/cache@v4, chave por hash dos*.gradle*): cacheia deps + wrapper entre runs; sem ele o runner efêmero rebaixa tudo.--no-daemonsegue correto (daemon não sobrevive ao container). - Asset gerado no build (ex.
powersync setup_web, JS gitignored): o gate gera antes do test (espelha o Docker), senão falha em checkout limpo.
App "só-script" (ex. um .mjs sem suíte): o piso é análise estática sempre
(ESLint etc.); testes cobrem a lógica pura onde existir; script trivial não é
obrigado a inventar suíte, mas lint não é opcional. E2E é opcional.
Higiene de saída de comando (disciplina de tokens)¶
O contexto de um agente de IA é recurso escasso e caro. Saída de comando tem dois públicos: o terminal humano (progresso, spinner, contagem contínua) e o agente (resultado + o que falhou). Corte o verboso na fonte — antes de entrar no contexto, não depois. O princípio vale com ou sem proxy de compressão de tokens.
- Test runner: reporter frugal, sempre. Verde ≈ 1 linha; vermelho = só as falhas. O
reporter interativo default despeja dezenas de linhas de progresso por run (
+N: <path>) — UI de terminal, ruído puro para o agente (foi o maior consumo de tokens de uma sessão real). - Ferramentas nativas de arquivo (ler/buscar/glob) em vez de
cat/grep/findno shell — mais frugais e sem despejar o arquivo inteiro no contexto. - Corte o verboso na fonte: flags de reporter (
--quiet/--console=plain/-r failures-only) egit diff --statantes do diff cheio. Para pós-processar com| tail/jq, lembre: se você usa o rtk (padrão da casa — ferramentas), o pipe fura o hook → prefira o comando bare (o rtk resume). Traga o mínimo que decide o próximo passo. - Não confunda UI de terminal (progresso, spinner) com saída de agente (resultado): nunca traga a primeira quando só precisa da segunda.
- Proxy de compressão (se houver) é complemento, não substituto. Ganha em saída
volumosa-compressível (build logs,
git diff,ps), ~0% no resto, e pode não cobrir a toolchain da stack. Não terceirize a disciplina para ele; se ele promete um hook "transparente", verifique que está instalado.
Comando frugal por stack:
| Stack | Comando |
|---|---|
| Flutter | flutter test -r failures-only · flutter analyze (já enxuto) |
| Dart | dart test -r failures-only |
| Java/Micronaut (Gradle) | ./gradlew test --quiet --console=plain |
| Node | npm test --silent (reporter dot/min) |
| Docs (MkDocs) | mkdocs build --strict -q |
| git | git diff --stat antes do diff cheio; git log --oneline -n |
Definição de pronto — o detalhe (testes)¶
O piso (§6 da constituição): nenhuma regressão sem teste de regressão, e a verificação
exercita a plataforma/runtime real (Web, mobile, o build do app), não só
--strict/fixture. O detalhe por nível:
- Unitário — lógica pura (parsers, conversores, regras, limites).
- Integração — repositórios, storage, contratos externos (mock HTTP / containers).
- E2E — obrigatório em fronteira de segurança ou de contrato (auth, endpoint
público, troca de formato); opcional no resto. Endpoint Micronaut que precisa de dados
semeados: seed no
@BeforeEach/@Sql, nunca no corpo do teste (o servidor embutido lê em outra conexão e não vê o rollback) — mecânica em java-micronaut § Testes e fixtures. E2E que sobe um servidor pede porta efêmera e lê a URL real do próprio servidor (objeto de teste ou log de startup), nunca uma porta fixa: a porta chutada colide com processo vivo ou zumbi e o teste fica flaky ou bate em outro servidor — o porquê e o fallback (matar por porta) em agentes § Loop de verificação. View server-render é fronteira de contrato (o HTML é a interface): o./gradlew checknão renderiza template, então armadilha de render passa verde — o teste de fio é um@Clientque fazGETe renderiza de verdade, checando um pedaço do HTML; mecânica e a família que ele pega em java-micronaut § Testes e fixtures. - Paridade em refactor — refactor sem mudança externa mantém o gate verde e, quando há referência (ex. saída de um fluxo legado), confere a paridade contra ela.
- Caracterização precede refactor — mesmo nome ≠ mesmo corpo: antes de virar tarefa de
plano um "unificar/deduplicar N iguais", leia os corpos e confirme a identidade (caso real: 6
variantes
_Measureque pareciam 1). Ligar os lints/guardas cedo (na caracterização) pega bug antes do refactor, não depois. - Deferir teste é custo × valor, não racionalização — "exercitado end-to-end" e "baixo risco" são justo as frases que deixam a guarda de regressão barata escapar. O default é escrever a guarda barata e proporcional agora; só se defere no quadrante caro E de baixo valor (e o deferimento se declara — REGRA Nº 3); comportamento sensível (segurança/privacidade/contrato) → a guarda se escreve agora, sempre.
- O teste não reimplementa a lógica que atesta — o defeito mais silencioso da lista:
o teste existe, roda, passa, e não exercita o que diz exercitar, porque refaz no
próprio corpo a conta que a produção faz. O
expecteddeixou de ser um oráculo e virou um segundo palpite da mesma cabeça que escreveu o código: se a regra estiver errada nos dois lugares, o teste concorda com o bug; se a produção mudar, o teste muda junto no mesmo commit e continua verde. Caso real: um teste de agregação somava as linhas no próprioteste comparava com a soma do serviço — verde sobre uma agregação que estava errada.- O sintoma: o corpo do teste tem um laço, um
sum/fold/groupByou umifque espelha a estrutura do código sob teste. Regra prática: se o teste importa a mesma ideia, não só o mesmo dado, ele não é um oráculo. - A cura: o esperado vem de fora do raciocínio da produção — valor literal
escrito à mão (
assertEquals(new BigDecimal("1350.75"), ...)), saída capturada de uma execução conferida por humano, ou invariante independente (a soma das partes é o total, o resultado é monotônico, o arredondamento fecha em 2 casas). Tabela de entrada→saída literal é o formato mais barato e o mais honesto. - Como detectar o que já existe: teste de mutação (Java: PIT).
Ele altera o bytecode da produção (troca
+por-, inverte condição) e roda a suíte; mutante sobrevivente = linha que nenhum teste observa. É o único jeito prático de distinguir teste que atesta de teste que acompanha — foi o que revelou o caso acima. Ferramenta de diagnóstico, rodada sob demanda contra uma classe suspeita, não gate: noci.ymlcusta minutos por build e vira gaming, mesma razão de "cobertura é visível, não bloqueante" (abaixo). Vale também à mão, sem ferramenta: quebre de propósito a linha que o teste deveria proteger e confirme que ele fica vermelho — teste que passa com o código quebrado nunca protegeu nada.
- O sintoma: o corpo do teste tem um laço, um
- Fixture de API externa é capturada, não inventada — teste verde sobre shape fabricado é falso atestado (quebrou produção 2×); provider externo só marca "ligado" com ≥1 resposta real capturada da instância. Convenção de captura/anonimização: java-micronaut § Testes e fixtures.
- Contrato de entrada first-party é travado nos dois lados — quando um cliente NOSSO
(skill, app, outro serviço) chama um endpoint NOSSO cujos campos obrigatórios importam
(ex. o broker
POST /api/setup/provisionque o/xadm-setupconsome), o contrato não pode viver só no cliente: o provedor tem um teste que rejeita payload sem os campos obrigatórios (400 legível, não 500), e o cliente declara os mesmos campos. Sem o par, os dois driftam em silêncio — o guia dizia(app_id, feature), o broker passou a exigir mais, e o resultado foi 500 em produção em vários apps antes de alguém notar. Distinto da fixture externa (acima): ali o risco é shape de resposta inventado; aqui é contrato de entrada não-verificado no provedor. O contrato operável do broker de setup vive em Central de Apps. - Fidelidade ao artefato empacotado —
testroda no classpath; o deploy sobe o fat jar/container. Merge deMETA-INF(serviços/beans), native-image e shading mudam ordem, descoberta e configuração → o verde do classpath mente sobre o que embarca. Primeiro elimine a divergência: descoberta/ordem/config determinísticas e idênticas nos dois ambientes. Só onde for inevitável, o gate sobe o artefato empacotado (jar/container) e bate num endpoint de saúde/smoke — complementa os testes de classpath onde eles não alcançam, não os substitui, e não é "smoke em prod" (é o gate exercitando o pacote no CI). Caso concreto em Micronaut: filtros@Filterlegado +@Order, cuja ordem só quebra empacotada — ver java-micronaut. - Cobertura é visível, não bloqueante (norma) — o gate imprime o % de cobertura no
check(visibilidade > métrica-alvo); a casa não exige piso bloqueante por padrão (piso pra todo mundo vira gaming — teste que toca sem asserir — e pune o legado sem melhorar cobertura real). Um piso bloqueante é opt-in por app: decisão emdecisoes/onde o risco justifica (serviço crítico), com o legado tratado por rampa/isenção declarada. O ferramental é por stack (Java = JaCoCo, atenção à versão vs class-major do Java — java-micronaut §Testes; Flutter =flutter test --coverage). - Verificador não muta a árvore de trabalho — gate é check, nunca fix. Formatador em modo
de escrita — que é o default do
dart format, e o quespotlessApply/ktlintFormatfazem — sai1e conserta os arquivos de passagem: rodar o gate de novo dá verde, o vermelho parece transitório e a correção fica não-commitada. O CI, que faz checkout limpo da tag, reprova o que "passou" na máquina. Use sempre a variante de checagem pura —dart format --output=none --set-exit-if-changed .,spotlessCheck,ktlintCheck(o./gradlew checkjá as agrega). Corolário: se o gate mudou arquivo, o gate estava errado.
O como de cada ferramenta (a guarda de fronteiras, WireMock, etc.) vem nas páginas por stack e nas skills, conforme os pilotos fecham.