Pular para conteúdo

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 em check/build (ex. checkstyle, flutter analyze) precisa falhar o PR, não escapar para o master. Por stack:
    • Java/Gradle./gradlew check
    • Flutterdart format --output=none --set-exit-if-changed . && flutter analyze && flutter test
    • Dartdart format --output=none --set-exit-if-changed . && dart analyze && dart test
    • Nodenpm 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 --strict não parseia Mermaid; um ; numa mensagem de sequenceDiagram já quebra o build e trava o deploy sem aparecer no gate local; também reprova flowchart LR/RL grande, que a constituição §5 pede em TB). Espelhe TODO esse conjunto localmente antes do push (o /x-implementar e o pré-flight do /xadm-release rodam o lint-mermaid; precisa npm i --no-save mermaid@11 jsdom).
  • release.yml = rede de segurança do release (push de tag): roda o valida-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 corporequired, 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:

  1. 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 @DisabledOnOs some. Reserve o disable a teste genuinamente Linux-only (unix socket, permissão de arquivo, script sh).
  2. 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. O gradlew precisa de fim-de-linha LF no checkout Windows (.gitattributes com gradlew text eol=lf); se o teste usa Testcontainers, monte o socket do Docker (mesma topologia dind da CI — o runner injeta DOCKER_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 ./gradlew em cache frio não baixa, adendo 0003) e ci-flutter:<v> (SDK Flutter baked — mata o git clone, que era o maior custo do CI Flutter). O container: do ci.yml/docs.yml aponta a da stack, na versão declarada no app.json (toolchain.*). App Java/Flutter é OBRIGADO a declarar toolchain.{java|flutter} (o validador falha sem ele) — é o que a fábrica usa para buildar a imagem-versão; sem a declaração o container: 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-daemon segue 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.

  1. 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).
  2. Ferramentas nativas de arquivo (ler/buscar/glob) em vez de cat/grep/find no shell — mais frugais e sem despejar o arquivo inteiro no contexto.
  3. Corte o verboso na fonte: flags de reporter (--quiet/--console=plain/-r failures-only) e git diff --stat antes 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.
  4. Não confunda UI de terminal (progresso, spinner) com saída de agente (resultado): nunca traga a primeira quando só precisa da segunda.
  5. 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 check não renderiza template, então armadilha de render passa verde — o teste de fio é um @Client que faz GET e 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 _Measure que 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 expected deixou 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óprio test e 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/groupBy ou um if que 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: no ci.yml custa 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.
  • 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/provision que o /xadm-setup consome), 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 empacotadotest roda no classpath; o deploy sobe o fat jar/container. Merge de META-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 @Filter legado + @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 em decisoes/ 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 que spotlessApply/ktlintFormat fazem — sai 1 e 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 check já 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.