
Arachne: o deploy que nunca rodou porque as duas metades nunca se cruzaram
O workflow que estava verde porque não rodava
Em 15 de outubro entraram dez commits no Arachne. Cinco deles, os PRs #96 a #100, foram gastos fechando um circuito que parecia pronto há semanas: construir a imagem do projeto e subir em produção por tag. O CI não estava vermelho. Ele estava vazio — e workflow vazio tem exatamente a cor de um workflow que passou.
Duas metades que nunca se encontravam
O job build só rodava em push para master. O job deploy só rodava em push de tag v*. São conjuntos disjuntos: como as duas condições nunca se cruzam, nenhuma tag jamais chegou a buildar nem a deployar. O workflow tinha deploy, tinha tag, tinha imagem — e nada disso se tocava.
O commit 2da3f49 (PR #100) passa o build a rodar em master e em tag, publicando a imagem com a etiqueta latest que o deploy consome. Uma condição destravou o resto.
O SSH para um host embaixo do próprio pé
O job de deploy autenticava por DEPLOY_HOST, DEPLOY_USER e DEPLOY_SSH_KEY. Era caminho de outra era, quando a produção vivia em k3s, em outro host. Depois disso a produção passou a ser um serviço systemd que roda docker compose na mesma máquina onde o runner self-hosted está registrado.
Ou seja: o deploy discava SSH para fora para chegar a um host que, na prática, era a máquina de onde partia — e que já não respondia por aquele caminho. O 59cd76a (PR #98) troca o SSH por deploy local: login no registro com o GITHUB_TOKEN do próprio job e a permissão packages: read, pull da imagem, docker tag e docker compose up -d. Sem host remoto, sem chave privada, sem salto de rede.
O health-gate que desistia na primeira falha
O job antigo fazia pull, up, sleep e um curl. Se o health não respondesse de primeira, ele saía com erro e pronto: o container novo ficava no ar e não havia caminho de volta. O commit 12a6840 (PR #96) desenhou o health-gate que existe hoje — 12 tentativas com 5 segundos entre elas, cerca de 60 segundos de paciência, porque o start do serviço passa de 10 segundos com folga. Antes de subir, o job guarda a imagem que está no ar (docker inspect --format '{{.Image}}'); se o health não vier, ele reetiqueta a imagem anterior e sobe de novo. Rollback automático em vez de intervenção manual no meio da madrugada.
O mesmo commit corrige uma mentira silenciosa do compose de produção: ele apontava para uma imagem de nome local, então o “deploy” recompilava a imagem na máquina a partir do código que o script de deploy não atualizava. A imagem no ar podia não ter nada a ver com o commit que a tag representava. Agora o compose referencia a imagem publicada pela CI.
O CI que não conhecia o sistema
Na mesma noite, todos os jobs que precisavam de Python morriam no primeiro passo: o setup-python não reconhece a distribuição do runner — o erro é literal, version 3.12 was not found for cachyos — e o runner é Arch-based. test, lint, pip-audit, ruff, bandit e a publicação no PyPI morriam antes de rodar uma linha de código. E como o build dependia deles, o caminho até o deploy morria junto.
A cura (PRs #97 e #99) tirou o setup-python de cena e passou os jobs para container: python:3.12-slim no trabalho pesado e python:3.11-slim na publicação. Dois efeitos colaterais apareceram no mesmo pacote e foram resolvidos junto:
- o serviço Postgres passou a ser acessado pelo nome do serviço (
postgres:5432) em vez do mapeamento de porta da máquina (localhost:5433) — dentro do container, o “localhost” é outro; - o job do bandit usava
sudo installnum runner que não tem sudo; no container, que roda como root, o mesmo comando deixa de precisar de sudo.
E um bônus no mesmo commit: o token do gitleaks estava escapado no YAML e virava string literal. A checagem rodava com uma credencial de mentira e reportava sucesso.
Duas correções que apareceram no caminho
O mesmo dia trouxe o 612fee0: a checagem de saúde do armazenamento chamava uma função que só existe no caminho do Postgres com vetores, e no store em SQLite — o que estava em produção — aquilo virava 500 durante a ingestão. E o guard de CSRF bloqueava cliente de API puro, que autentica por cabeçalho e não envia cookie: sem cookie, não há CSRF a proteger, e a automação ficava travada por uma proteção que não se aplicava a ela.
Por que isso importa
São três falhas silenciosas de espécies diferentes. A primeira é aritmética: duas condições que nunca se cruzam não dão erro, dão nada — e nada tem a mesma cor de sucesso. A segunda é um destino morto: SSH para um host legado falha sem que ninguém esteja esperando aquele deploy. A terceira é uma credencial que virou texto, com o relatório da checagem dizendo que passou.
A única peça do circuito desenhada para gritar era o health-gate — e ela desistia na primeira tentativa, deixando o container novo no ar.
O que muda agora é o que o deploy afirma: a imagem que sobe é a que a CI construiu, o health tem tempo de responder, e existe rollback quando não responde.
# o deploy local: a imagem que a CI publicou vira o que roda
docker pull ghcr.io/<org>/arachne-api:latest
docker tag ghcr.io/<org>/arachne-api:latest arachne-api:latest
# o health-gate: 12 tentativas de 5s antes de decidir o rollback
for i in $(seq 1 12); do curl -sf http://127.0.0.1:9000/health && break; sleep 5; done
Próximo passo
O health-gate valida que o serviço responde, não que a versão que responde é a que acabou de subir. O próximo passo é comparar o identificador da imagem em execução com o da imagem que a CI acabou de puxar e reprovar o deploy quando os dois não batem — a mesma classe de mentira silenciosa que esta noite mostrou três vezes.
Leia também
Mesmo projeto, mesma trilha — as histórias se conectam.
Próximo passo
Se isto te serviu, tem mais: escrevo sobre o mesmo assunto nos outros projetos, e a loja tem as ferramentas que eu uso aqui.
Anotado — aviso quando sair artigo novo.
Sem spam, descadastro de um clique. Também estou aqui: Loja de skills · Portfólio