cigithub-actionsdevopstestesastro

Portões de qualidade que passam sem rodar: quatro modos de falha para caçar no seu CI

Douglas Haruo 11 min 23/08/2026

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.


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):

arquivoslinksúnicoserros
antes (./dist/**.html …)217051500
depois (lista por find)7429172120

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: true nos 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:

  1. @astrojs/check e typescript não estavam nas dependências. O comando só imprimia o prompt de instalação e saía sem checar nada.
  2. 2>&1 || true somado a continue-on-error: true garantia 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.tsRequest é a classe do Workers em runtime, mas o tipo da lib DOM a sombreia. Dois casts re-rotulam o valor com a forma que env.ASSETS.fetch espera; nada muda em execução.
  • AntesDepois.astro, PreviewGallery.astroquerySelector devolvia Element, e o script lia .style, .value, .dataset, .src e .alt. Passou a pedir o tipo certo na consulta.
  • RelatedPosts.astroallPosts aceitava só CollectionEntry<'blog'>, mas as páginas /en/ e /zh/ passam blogEn e blogZh. Tipo alargado para as três coleções.
  • blog/author/[slug].astro — o og:image era author.avatar || '/og-image.png', e AuthorData não tem campo avatar: 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:

  1. 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.
  2. set -o pipefail sempre que houver |. O tee para 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.
  3. A ferramenta se apresenta antes de trabalhar. Um --version depois 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 →