Quando o GitHub Actions morre por billing: o runbook do modo de falha silencioso
O sintoma é ridiculamente pouco informativo: o job aparece vermelho, dura cerca de 2 segundos, não aloca runner, não executa um único step, e não deixa log nenhum. Nenhuma mensagem sobre cota, nenhum link para o faturamento, nada. Só um X.
Quando a cota de minutos hospedados de uma conta pessoal do GitHub acaba, é isso que você vê. E o que acontece depois depende inteiramente de uma pergunta que quase ninguém faz antes: o que exatamente parou?
Há duas respostas possíveis para essa pergunta, e um mesmo episódio de cota estourada as produziu lado a lado, em dois repositórios. As duas são instrutivas justamente por serem opostas — e a diferença entre elas é a diferença entre um susto e um apagão silencioso.
Caso A: o deploy parou, e por isso alguém percebeu
No Tamperlens, o deploy roda por GitHub Actions — push em main, CI verde, runner na VPS redeploy. Quando a cota acabou, o deploy parou junto. Foi notado no mesmo dia, e os workflows críticos migraram para runner self-hosted dois dias depois.
Isto é o caso feliz. O acoplamento entre “checagem” e “publicação” é normalmente um defeito de design, mas aqui ele funcionou como detector: você não consegue ignorar um CI morto quando ele é o mesmo caminho pelo qual o produto chega ao ar.
Caso B: o deploy não parou, e ninguém percebeu
No haruo.dev foi o contrário, e é o caso mais perigoso dos dois.
O site publica pela integração git do Cloudflare Pages, não por Actions — todo push em main dispara build e deploy direto na Cloudflare, fora do GitHub. Quando a cota acabou, o site continuou no ar, normalmente, atualizando a cada push, com lint, testes e link check desligados — e absolutamente nada indicando isso.
Todo commit da semana seguinte foi para produção sem passar por nenhum portão. Não porque alguém decidiu pular, mas porque o portão morreu numa dimensão que ninguém estava olhando.
A regra que fica: todo repositório tem um caminho de publicação e um caminho de verificação. Se os dois são o mesmo sistema, um cai com o outro e você descobre. Se são sistemas diferentes — Pages publica, Actions verifica — você precisa monitorar os dois separadamente, porque um deles pode morrer sem sintoma. E o que morre sem sintoma é sempre o de verificação, porque ele não tem usuário.
O caso feio: a página de status congelada por 4 dias
O terceiro efeito é o mais perigoso dos três, e vale contar com os números.
status.tamperlens.com ficou congelada desde 2026-08-19 17:13 UTC. O uptime.yml roda por cron, e desde aquele horário toda execução agendada morreu nos mesmos ~3 segundos, sem runner e sem log: 165 falhas consecutivas.
A página congelada é a metade visível do problema. A metade invisível é que o probe também era o alarme. Desde o dia 19, nada estava checando a produção. Se a API tivesse caído, nada teria dito.
Quatro dias com o monitoramento desligado, e o único sinal disso era uma coluna de X num histórico de Actions que ninguém abre quando está tudo aparentemente bem.
E aqui está o detalhe estrutural: quando os outros workflows migraram para o runner self-hosted, o uptime.yml não pôde seguir junto. Esse runner é a caixa vigiada. A própria página diz, no texto dela, que é sondada de um lugar que não é o servidor. Mover o probe para dentro da caixa não teria consertado a página — teria feito ela mentir.
Isso é a versão de infraestrutura do problema do post anterior: um portão que não pode reprovar. Um probe que roda na máquina que ele monitora não pode reportar que a máquina caiu. Ele reporta silêncio, e silêncio se parece com “sem dados”, que se parece com “está tudo bem”.
Saída 1: runner self-hosted na VPS
A saída óbvia, e a que resolve a maioria dos casos: registrar um runner self-hosted na máquina que você já paga.
Funciona, e é o que os workflows de CI e de deploy usam hoje. Três coisas que custaram tentativa, e que valem estar escritas em algum lugar:
- Ferramenta em contêiner, nunca instalada no host. O runner não deve acumular estado. Mas se o workspace é montado read-write e o contêiner roda como root, você deixa arquivos de root no workspace do runner — que depois quebram o próximo
npm ci. Rodar com--user $(id -u):$(id -g)resolve. E$(id)não expande dentro de um blocoenv:do YAML; isso custa um run. - Cache do npm dentro do workspace, não num volume docker nomeado. Volume nomeado nasce pertencendo ao root; o contêiner roda como usuário; o npm falha com
error writing to the directory. No workspace o dono está certo e o cache ainda sobrevive entre runs. HOME=/tmp. O npm exige umHOMEgravável, e o do usuário do runner não existe dentro do contêiner.
E o custo que vale declarar em vez de esconder: este virou o terceiro runner na mesma caixa. Três serviços, três atualizações, três pontos de falha — porque runner de organização não existe em conta pessoal, e o GitHub não deixa três repositórios apontarem para o mesmo runner fora de uma org. É um custo consciente, não um descuido. Anotar isso no PR é mais barato do que redescobrir em seis meses por que a caixa tem três systemd units parecidas.
Saída 2: tirar o vigia da caixa vigiada
Para o probe, runner self-hosted não serve — pela razão acima. A saída foi outra: um Cloudflare Worker com cron, que sonda, dobra o resultado num histórico em KV e serve a página. A borda da Cloudflare não é a caixa nem é o GitHub, então a premissa da página sobrevive.
Duas decisões desse porte merecem nota, porque são o tipo de coisa que estraga uma migração de monitoramento:
O renderizador não foi reimplementado. O núcleo saiu inteiro do script CLI para um módulo compartilhado, e os dois lados importam o mesmo arquivo. O Worker não pode divergir da CLI sobre o que a página diz. Os 32 testes existentes continuam passando pelo caminho antigo, que reexporta tudo.
Os cinco ids de componente ficaram iguais (api, site, checker, pricing, canonical). Um id não é um rótulo: é chave de armazenamento no histórico. Renomear um órfã silenciosamente todo o passado daquele componente — você ganha uma página bonita e perde os dados que davam sentido a ela.
A redução, declarada em vez de escondida
O probe em Node fazia mais do que o Worker consegue fazer: ele executa os scripts servidos contra um DOM e exige que o relatório da demo renderize, além de uma perna autenticada por token no POST /api/v1/inspect. Nada disso é portável para um Worker — não há DOM, não há eval. O Worker para na camada estática.
Essa camada não é o prêmio de consolação. É exatamente a que teria pego o incidente de 2026-08-04, quando um erro de sintaxe num script deixou todas as páginas do checker mortas por dois dias enquanto o HTML continuava respondendo 200. A descrição do componente diz o que ele de fato checa agora, e um teste falha se alguém copiar a redação mais generosa do probe antigo por cima.
O workflow_dispatch continua no uptime.yml, então o exercício mais profundo está a um clique de distância antes de um release.
O teste que sustenta tudo
Workers não têm node:vm, nem eval, nem new Function. Então a função que analisa o bundle servido passou a receber o parser como argumento: o Node injeta node:vm, o Worker injeta acorn.
Se os dois discordassem, os dois pontos de observação reportariam veredictos diferentes sobre o mesmo arquivo servido — o que é pior do que qualquer um dos dois estar errado sozinho. Por isso o teste afirma que eles concordam em oito fontes, incluindo a quebra real de 2026-08-05, sintaxe moderna, template literals com chaves, ambiguidade entre regex e divisão, e três erros de sintaxe deliberados.
Total: 1.591 testes, 0 falhas (1.577 anteriores + 14 novos).
O que ainda não está resolvido, e por quê
Sendo honesto sobre o estado: o Worker registra um probe falho com console.error, o que chega no wrangler tail e em mais nada.
Ele restaurou a página, não o alarme.
O que é uma melhora real — a página voltou a ser verdadeira, e um humano que a abrir vê o estado atual — mas não fecha o buraco que causou a história inteira. Um apagão de quatro dias foi invisível precisamente porque ninguém vigiava o vigia. Trocar um vigia mudo por outro vigia mudo em lugar melhor é meio caminho.
O runbook, curto
Se seus jobs estão morrendo em 2 segundos sem log:
- Confirme que é billing.
gh api repos/<owner>/<repo>/actions/runs --jq '.workflow_runs[0]'e olhe a duração. Zero step executado + segundos de duração + nenhum log = cota, não código. Não vá caçar bug no repositório; não tem bug no repositório. - Liste o que parou, não o que ficou vermelho. Deploy, testes, lint, link check, cron de probe, cron de backup. Para cada um, pergunte: isso tem um sintoma visível se parar? Os que não têm são a sua exposição real.
- Cheque o que congelou. Página de status, badge de cobertura, dashboards alimentados por Actions. Um número que parou de se atualizar parece um número correto. É a forma mais educada de mentira que um sistema pode contar.
- Migre por categoria, não por urgência. Build e teste podem ir para runner self-hosted. Probe e alarme não podem ir para a máquina que eles observam — esses precisam de um terceiro lugar (edge, um provedor externo de uptime, qualquer coisa que caia por motivos diferentes dos seus).
- Coloque um dead-man switch em tudo que é periódico. A regra é simples: sistema que fala quando dá errado não detecta a própria morte. Sistema que fala quando dá certo detecta — a ausência do ping é o alerta. É a única topologia em que “silêncio” significa “problema” em vez de “tudo bem”.
E a frase que eu escreveria na parede:
Quem vigia não pode morar na caixa vigiada. E o vigia precisa de alguém que note quando ele para de falar.
O segundo item dessa frase é a peça que essa topologia ainda não fecha. Fica registrado aqui como o próximo passo, não como conclusão.
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 →