Portões de qualidade que passam sem rodar: quatro modos de falha para caçar no seu CI
Existe um modo de falha de CI que não aparece em nenhum dashboard: o portão de qualidade que carimba ✅ sem nunca ter feito o trabalho. Não há stack trace, não há job vermelho, não há alerta — o passo executa, retorna zero, e o comentário automático do PR diz que está tudo certo. O que ele não diz é que não olhou para nada.
É um padrão traiçoeiro porque se disfarça de sucesso, e por isso costuma durar meses antes de alguém notar. O que segue é um teardown de quatro variações reais, todas no mesmo pipeline de um site estático (o CI deste blog serve de cobaia), com o número de cada uma e a defesa contra cada uma. O interesse não está em nenhuma isolada — está no padrão, e no teste de cinco minutos, no fim, que pega todas elas de uma vez.
Portão 1: o link check olhava 21 de 74 páginas
O quality-gate.yml roda o lychee contra o dist/ buildado, em dois passos: links internos e links externos. Os dois recebiam a mesma lista de arquivos:
./dist/**.html ./dist/**/index.html
Parece razoável. Não é. O ** do bash só desce recursivamente em subdiretório com shopt -s globstar ligado, e o shell do run: do GitHub Actions não liga isso por padrão. Sem globstar, ** é um * comum. O que chegava ao lychee era, na prática:
./dist/*.html # a raiz
./dist/*/index.html # exatamente um nível abaixo
Tudo de dois níveis para baixo ficava de fora: /pt/blog/<post>/, /zh/blog/<post>/, /produtos/<slug>/. Ou seja, o site inteiro fora da raiz — que é onde mora praticamente todo o conteúdo.
A medida, feita com o mesmo lychee e as mesmas flags, rodada sob /bin/bash para reproduzir o run: (e não sob zsh, que expande ** sozinho e por isso esconde o bug quando você testa na sua máquina):
| arquivos | links | únicos | erros | |
|---|---|---|---|---|
antes (./dist/**.html …) | 21 | 705 | 150 | 0 |
depois (lista por find) | 74 | 2917 | 212 | 0 |
Zero erro nos dois casos. O ganho aqui é cobertura, não link quebrado encontrado — e essa distinção é o assunto do post inteiro.
A correção não foi ligar o globstar. Foi tirar a lista das mãos do shell: um passo só gera a lista com find, os dois passos do lychee leem o mesmo arquivo. Assim não depende de opção de shell nem da versão do bash do runner, e duas listas escritas à mão não podem divergir uma da outra. O passo também imprime N HTML pages built e faz um test -s na lista — porque a diferença entre “não achei link quebrado” e “não olhei para nada” precisa estar no log.
Detalhe pequeno com razão específica: a leitura usa while IFS= read -r e não mapfile, para não exigir bash 4+. O trecho novo foi rodado também em bash 3.2 — mais velho que o do runner — para confirmar.
Portão 2: o binário morria no linker, dentro do contêiner
Com o glob corrigido, o primeiro run da branch mostrou o log de verdade:
./.bin/lychee: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found (required by ./.bin/lychee)
./.bin/lychee: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found (required by ./.bin/lychee)
O lychee é baixado no host do runner e executado dentro do contêiner node:22, que é Debian bookworm — glibc 2.36. O build -unknown-linux-gnu do lychee 0.24.2 exige 2.38/2.39. O binário nunca chegou a abrir um único arquivo. Com glob certo ou errado, tanto fazia.
Ou seja: a cobertura de 21 arquivos medida acima é a cobertura que o passo teria tido se ele tivesse rodado. Ele não rodava desde que passou a rodar em contêiner.
A correção é o build musl, que é estático e não depende da libc da imagem. E o passo de instalação agora roda lychee --version logo depois do download — para falhar onde o problema está, e não dois passos adiante, com uma mensagem sobre outra coisa.
Portão 3: e o ”✅” era falso
Aqui está o motivo de os dois anteriores terem durado tanto.
lychee ... | tee link-check.log || echo "ISSUES=true" >> $GITHUB_OUTPUT
O || avalia o status do último comando do pipe, que é o tee. E tee sai 0 sempre. O flag ISSUES nunca subia. O comentário automático do PR anunciava ”✅ No broken links” — com o log de GLIBC logo abaixo, no mesmo comentário, cheio de erro.
A mentira estava automatizada e colada no PR toda vez.
Correção: set -o pipefail nos dois passos. Agora tanto um link quebrado quanto uma ferramenta que não roda derrubam o flag. A distinção entre os dois casos fica no log, que o comentário já embute.
Vale separar as duas coisas que esse || true estava fazendo:
- Não bloquear o merge por link externo fora do seu controle — isso é uma decisão de produto legítima, e continua valendo (
continue-on-error: truenos passos de link check). - Não conseguir distinguir “verde” de “não executou” — isso não é decisão, é bug.
O primeiro você quer. O segundo mata o primeiro.
Portão 4 (bônus): o lint que nunca foi instalado
O mesmo padrão, num passo diferente. Os dois workflows rodavam astro check como passo de lint. Era um no-op duplo:
@astrojs/checketypescriptnão estavam nas dependências. O comando só imprimia o prompt de instalação e saía sem checar nada.2>&1 || truesomado acontinue-on-error: truegarantia que ele não pudesse falhar mesmo que tivesse rodado.
Duas camadas independentes de “isto não pode reprovar”. Qualquer uma sozinha já bastava.
Instalado de verdade, o check acusou 62 erros. Mas 54 deles eram artefato de não existir tsconfig.json no repo: sem ele, os tipos gerados em .astro/types.d.ts não entram no programa e todo getCollection() volta como never. Um tsconfig.json de cinco linhas estendendo astro/tsconfigs/base derruba os 54 de uma vez.
Os 12 restantes eram reais:
functions/_middleware.ts—Requesté a classe do Workers em runtime, mas o tipo da lib DOM a sombreia. Dois casts re-rotulam o valor com a forma queenv.ASSETS.fetchespera; nada muda em execução.AntesDepois.astro,PreviewGallery.astro—querySelectordevolviaElement, e o script lia.style,.value,.dataset,.srce.alt. Passou a pedir o tipo certo na consulta.RelatedPosts.astro—allPostsaceitava sóCollectionEntry<'blog'>, mas as páginas/en/e/zh/passamblogEneblogZh. Tipo alargado para as três coleções.blog/author/[slug].astro— oog:imageeraauthor.avatar || '/og-image.png', eAuthorDatanão tem campoavatar: nenhum autor jamais definiu um. Ramo morto, removido.
Nenhum deles derrubava o site. Todos eram bugs latentes esperando um caminho de código específico.
O detalhe que fecha essa correção: o dist construído antes e depois é byte a byte idêntico em todas as páginas — só mudam os hashes do bundle do worker e a ordem da lista de exclusão do _routes.json. Doze erros de tipo corrigidos, zero mudança de comportamento. Que é exatamente o que se espera de um lint que passou dois anos desligado: ele não estava escondendo um incêndio, estava escondendo doze pequenas dívidas.
O primeiro sinal honesto
Depois das quatro correções, o link check rodou de verdade pela primeira vez: 74 arquivos, 2917 links, 212 únicos — e 10 erros. Todos pré-existentes, nenhum causado pelas mudanças.
Eles ficaram registrados no PR em vez de silenciados, porque é o primeiro sinal honesto que aquele passo já deu. Entre eles: um repositório do GitHub que virou 404, um subdomínio cujo DNS não resolve mais, e um servidor externo que rejeita o SNI do TLS. Nada catastrófico — mas 10 links quebrados que o portão jurava não existirem.
E vale a comparação: 21 → 74 arquivos e 705 → 2917 links checados, com 0 → 10 erros achados. O portão não ficou mais rigoroso. Ele começou a existir.
A defesa, que é uma frase só
Portão que não pode falhar não é portão. É decoração com custo de manutenção.
O teste operacional é simples e leva cinco minutos: quebre a ferramenta de propósito e veja se o CI reclama. Aponte o linter para um arquivo que não existe. Troque o binário por um que não roda. Faça o teste falhar.
Se o job continua verde, você não tem uma checagem — você tem um badge.
Três exigências que valem para qualquer passo de CI, e que teriam pego cada um dos quatro casos acima no dia em que entraram:
- O passo imprime o denominador. “0 links quebrados” não diz nada; “0 links quebrados em 2917 links de 74 arquivos” diz tudo. Um número sem denominador é indistinguível de um número que não foi medido.
set -o pipefailsempre que houver|. Oteepara log é o caso mais comum e o mais traiçoeiro, porque o log fica logo ali do lado, cheio de erro, dizendo o contrário do resumo.- A ferramenta se apresenta antes de trabalhar. Um
--versiondepois do download custa 200 ms e transforma “0 erros encontrados” em “0 erros encontrados por uma ferramenta que existe”.
O detalhe que fecha a história: o run que descobriu o problema do GLIBC foi o run do próprio PR que consertava o glob. Foi o portão consertado pela metade que denunciou a outra metade. É assim que costuma funcionar — você não descobre que o CI mente auditando o CI. Você descobre quando ele finalmente fala.
Precisa de um projeto técnico sob medida?
Arquitetura, TypeScript, APIs e automação, do protótipo à produção. Quem responde o seu e-mail é quem escreve o código, e o prazo que eu prometo é o prazo que eu consigo cumprir.
Me manda uma mensagem →Posts Relacionados
Quando o GitHub Actions morre por billing: o runbook do modo de falha silencioso
10 min
Runner self-hosted do GitHub Actions com Docker: o setup que sobrevive a billing
11 min
Astro vs Next.js vs SvelteKit: como escolher sem tabela inventada
9 min