cicd · diff

v2.34.0 to v2.34.1

4 added, 4 removed. Audit B to B.

---
name: cicd
metadata:
- version: 2.34.0
+ version: 2.34.1
description: "GitHub Actions / Docker / GHCR pipeline troubleshooting and config, auto-routed by stack (Node/Prisma, Django/gunicorn, Vite). Self-hosted runner runbook — what breaks when you move a billed job onto one, which runner to trust for production — deploy-time proof (rollback with re-smoke, backup gates that check the dump), and Actions minute economics. Triggers — CI/CD, GitHub Actions, job never starts, Actions minutes/quota, GHCR auth, self-hosted runner, deploy queued, rollback/backup gate."
---
# CI/CD Skill — GitHub Actions, Docker & GHCR (Unified)
Skill for troubleshooting and configuring CI/CD pipelines. Detects the project type and routes to specific references.
---
## Project Detection
| Indicator | Project |
| --------------------------- | --------------------- |
| `prisma/schema.prisma` | **Backend** |
| `biome.jsonc` / `biome.json`| **Backend (Biome)** |
| `manage.py` / `requirements.txt` | **Backend (Django)** |
| `vite.config.ts` | **Frontend** |
> **Linter detection:** If the project has `biome.jsonc` (or `biome.json`), it uses Biome for lint/format. Otherwise, it assumes ESLint+Prettier. Biome projects do NOT use ESLint or Prettier.
> **Backend Django:** `manage.py`/`requirements.txt` (sem Prisma/Biome) → backend Python servido por gunicorn, testes com pytest. Roteia para `references/django-backend.md`. Scenarios `[B]` Django são marcados como tal nas tabelas.
> Check the project type before consulting references. Scenarios marked `[S]` are shared, `[B]` backend-only, `[F]` frontend-only.
---
## Workflow Overview
Both projects use **3 separate workflows** with identical triggers:
| Workflow | File | Trigger | Runners |
| ----------------- | ------------------- | ------------------------ | ------------------------------------------- |
| **CI** | `ci.yml` | PR → `develop` or `main` | `ubuntu-latest` |
| **CD Staging** | `cd-staging.yml` | Push → `develop` | `ubuntu-latest` + `self-hosted, staging` |
| **CD Production** | `cd-production.yml` | Tag `v*` | `ubuntu-latest` + `self-hosted, production` |
### CI Differences
```text
Backend (ESLint): checkout → install → prisma generate → lint → prettier → migrate → test (Jest)
Backend (Biome): checkout → install → [prisma generate] → biome check → [test if configured]
Frontend: checkout → install → lint → typecheck → test (Vitest)
```
> **Note:** `[prisma generate]` and `[test]` are optional — they depend on whether the project has Prisma and a configured test framework, respectively. Projects without a test framework (e.g., `estimates_api`) skip the test step in CI and CD.
### Deploy Differences
| Aspect | Backend | Frontend |
| -------------------- | ------------------------------------------ | --------------------------------------------- |
| Build-args | Does not need `environment:` in build job | `environment:` required (VITE_* secrets) |
| Image | Generic (same for all envs) | Environment-specific (VITE_* embedded in JS) |
| Migration | `prisma migrate deploy` before `up` | No migration |
| `VIRTUAL_PORT` | Required (`API_PORT` ≠ 80) | Not needed (nginx = port 80) |
| GHCR login on deploy | `docker/login-action@v3` before pull | `docker/login-action@v3` before pull |
| Prune | `docker image prune -f` | `docker image prune -f --filter "label=..."` |
| Compose path | Varies by project (e.g., `infra/nodejs/`, `infra/`) | Varies by project (e.g., `infra/<web>/`) |
### Concurrency & Auth
- **CI:** `ci-${{ github.ref }}` with `cancel-in-progress: true`
- **CD:** `deploy-{staging|production}-<project>` with `cancel-in-progress: false`
- **GHCR:** `GITHUB_TOKEN` (automatic) via `docker/login-action@v3` — no PAT
---
## Quick Troubleshooting
| Tag | Symptom | Probable Cause | Solution |
| ----- | ------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `[S]` | `unauthorized` on docker pull (self-hosted) | Docker login in different context (sudo vs user) | `docker login ghcr.io` in the same context that runs the pull |
| `[S]` | `Get "https://ghcr.io/v2/": net/http: TLS handshake timeout` on `docker login` (self-hosted runner; `build-and-push` on `ubuntu-latest` passes — only `deploy` fails) | Network-layer issue on the runner host (NOT credentials): MTU mismatch on VPN/overlay drops large TLS Certificate frames, or corporate TLS-inspection proxy stalls handshake | `troubleshooting-shared.md` §1a — Fix A: bash retry wrapper around `docker login` in the deploy job (3 attempts, 10s/20s backoff) absorbs transient flake; Fix B: lower Docker daemon MTU to `1400` in `/etc/docker/daemon.json` and restart docker on the runner host |
| `[S]` | `network declared as external, but could not be found` | Incorrect nginx-proxy network name in secret | `docker network ls \| grep proxy` and fix `NGINX_NETWORK_NAME` |
| `[S]` | `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` / smoke dies with `curl: (60) SSL certificate problem: self-signed certificate` | **Two causes, one symptom**: cert never issued (DNS/port 80/ACME) **or** cert exists and docker-gen was mid-reload after `up -d` (transient) | Read the cert FIRST: `openssl s_client -connect <d>:443 -servername <d> \| openssl x509 -noout -issuer -dates`. LE issuer + `notBefore` before the deploy = transient → `gh run rerun --failed`. Self-signed issuer = `dig` + ACME. `troubleshooting-shared.md` §3 |
| `[S]` | Deploy queued indefinitely | Self-hosted runner offline | `systemctl status actions.runner.*` on the server (host runner) **OR** `docker ps \| grep runner` (containerized — see `self-hosted-runner-docker.md` §7 if in `Restarting` with `404 /actions/runner-registration`) |
| `[S]` | Deploy queued **de novo** (§7 recorrente) — recovery por rotação de token não cura, `EPHEMERAL:false` também não | Credencial ainda é registration token (vence ~1h); entrypoint re-registra a cada restart | Fix durável = migração **ACCESS_TOKEN (PAT) in-place** — `self-hosted-runner-docker.md` §7 → "Migração ACCESS_TOKEN in-place". `gh` NÃO cria PAT (só web UI); `gh auth token` serve de stopgap |
| `[S]` | Deploy queued + runner crashloop; log `curl (22) 401` / `Invalid configuration provided for token` ao `Obtaining the token` (NÃO é o `404` do §7; outros runners do host seguem UP) | O PAT `ACCESS_TOKEN` (o "fix durável" da migração §7) **expirou/foi revogado** — PATs com validade recaem nisso na cadência de expiração (~mensal); isolado a um runner no modelo per-produto | `self-hosted-runner-docker.md` §10a — trocar SÓ a linha do PAT no `.env` persistente do host + `up -d --force-recreate`; validar o PAT novo via `gh api …/registration-token` ANTES; fix durável = PAT **sem expiração** ou lembrete de rotação |
| `[S]` | Deploy queued + runner crashloop (`RestartCount` milhares); log `Runner version vX is deprecated and cannot receive messages` (conectou e listou jobs antes de morrer) | Imagem `:latest` baixada uma vez e nunca re-puxada + `DISABLE_AUTO_UPDATE` ligado → binário apodrece até o GitHub recusá-lo. Ortogonal ao token (ACCESS_TOKEN não imuniza) | `self-hosted-runner-docker.md` §8 — `docker compose pull` + `up -d --force-recreate`; fix durável = **ligar auto-update** (§8a: REMOVER `DISABLE_AUTO_UPDATE`, pois qualquer valor não-vazio — até `"0"` — desliga) |
| `[S]` | Deploy `queued` + runner **`Up` há horas, `healthy`, `RestartCount` baixo** e `gh api …/runners` dizendo `online busy=false` — e o log termina em `Listening for Jobs`, SEM `deprecated` | Variante SILENCIOSA do §8: o GitHub migrou a entrega de jobs para o **Broker** e, no dia em que passa a EXIGIR o binário atual, o velho fica em laço (`BrokerMigration message received` no `_diag`, 1×/min) sem anunciar recusa. Todos os sinais óbvios dizem "saudável" | `self-hosted-runner-docker.md` §8b — o sensor é o **filesystem**: `ls -d /actions-runner/bin.*` mostra a versão nova baixada que nunca trocou. Triagem prévia: se **outro job do mesmo run** tem o mesmo `environment:` e executou, o Environment não é o gate |
| `[S]` | Runner pinado por digest com `DISABLE_AUTO_UPDATE` **ausente** (auto-update ligado) e mesmo assim preso numa versão velha | Em runner efêmero cujo entrypoint limpa o state, o update é **estruturalmente impossível**: ele baixa `bin.<nova>/`, sai para trocar, e o contêiner reinicia do `bin/` imutável da imagem. `RestartCount` sobe devagar (28 em 3 semanas), então não parece crashloop | `self-hosted-runner-docker.md` §8a — quem conserta é o bump do `FROM`, nunca o runtime |
| `[S]` | Deploy queued + runner crashloop; log `Failed to create a session. The runner registration has been deleted from the server` | GitHub apagou o registro (offline semanas); reuso de config (`CONFIGURED_ACTIONS_RUNNER_FILES_DIR` + named volume) reaproveita `.runner` morto em vez de re-registrar | `self-hosted-runner-docker.md` §9 — `docker volume rm <project>_<config-volume>` + `up -d` (distinto do §6: nada a deletar no GitHub; limpa estado LOCAL) |
| `[S]` | Deploy queued; ao corrigir um modo de falha o runner cai em OUTRO (o log MUDA: `registration has been deleted` → `401`/`Invalid configuration` → `version deprecated`) | §7/§8/§9 **empilhados** no mesmo runner — cada fix desmascara o próximo (o `docker volume rm` do §9 expõe um PAT expirado; o PAT novo expõe o binário deprecado) | `self-hosted-runner-docker.md` §10 — descascar de baixo p/ cima: limpar config morta → validar+trocar PAT → `compose pull`; **re-ler os logs após cada passo** |
| `[S]` | Deploy fica `queued` e ninguém percebe por dias/semanas — sem ❌, sem alerta (site segue no ar com a imagem velha) | Job self-hosted fica `queued` em silêncio; **`timeout-minutes` não conta tempo em fila** (só após o runner pegar o job) → quebra de runner nunca vira falha visível | Detecção proativa (`self-hosted-runner-docker.md` §11): **preflight gate** (falha o deploy no push se não há runner online com o label — exige PAT admin, `GITHUB_TOKEN` não lista runners) + **watchdog** agendado (cron, `actions:read`, alerta deploy preso) |
| `[S]` | CD step emits yellow `::warning::` on every deploy ("ENOENT" or similar in a script that finished its real work first) | Script writes output path resolved upward from `__dirname` — exists in dev source tree, missing in container image (Dockerfile only copies `packages/<self>/`); `continue-on-error: true` masks indefinitely | `cd-pipeline-pitfalls.md` §5 — wrap the write in try/catch best-effort and emit the artifact via `console.log` so CD logs capture it |
| `[S]` | `actionlint` sai **0** num workflow cujo `run:` tem shell quebrado (ou o gate de lint some sem ninguém mexer nele) | `shellcheck` ausente do PATH — o actionlint **desliga a regra em silêncio**, sem aviso e sem mudar o exit code; `-shellcheck=<inexistente>` também sai 0, não há flag que force a falha | `troubleshooting-shared.md` §12a — instalar shellcheck junto; provar com arquivo sabidamente ruim ou `actionlint -verbose \| grep 'was disabled'`; no CI, falhar o step se a regra estiver desligada |
| `[S]` | Gate/pre-flight imprime `EXIT_X=` **vazio**, ou `npm test \| tail` sai 0 com teste vermelho | Pipeline adota o status do `tail`; `${PIPESTATUS[0]}` é bash e no zsh expande vazio | Redirecionar p/ arquivo e ler `$?`, ou `set -o pipefail` — `cd-verification-and-rollback.md` §8 |
| `[F]` | Job de teste **verde** com 1.000+ linhas de `Error:`/`Warning:` no log | Ruído ambiente mascara o que carrega risco: `onUnhandledRequest: "warn"` deixa mock esquecido virar caminho de erro silencioso | Agrupar por assinatura e virar p/ `"error"` depois dos handlers — `troubleshooting-frontend.md` §10 |
| `[S]` | Deploy blocked | Concurrency group with previous run | Wait or cancel previous run via `gh run cancel` |
| `[S]` | `--max-warnings 0` fails in ESLint | Pre-existing warnings | Fix warnings or use `eslint-disable` |
| `[B]` | `manifest unknown` in service container | Discontinued Docker image | Switch to official image (e.g., `postgres:17`) |
| `[B]` | Zod validation error on boot | Missing env vars in CI or in Generate .env | Compare `src/env.ts` with `env:` block of tests and `printf` in CD |
| `[B]` | `ZodError invalid_string` | Secret URL missing `https://` protocol | Check secret format |
| `[B]` | Tests pass locally, fail in CI | Case-sensitivity in imports (Linux) | Fix file or import case |
| `[B]` | `FATAL ERROR: heap limit` / Exit 134 | Jest OOM | `node --max-old-space-size=4096` |
| `[B]` | `EADDRINUSE` in tests | `server.ts` calls `app.listen()` in test | Guard `NODE_ENV !== 'test'` |
| `[B]` | `ERR_CONNECTION_REFUSED` (nginx-proxy OK) | `VIRTUAL_PORT` not defined in compose | Add `VIRTUAL_PORT: '${API_PORT}'` |
| `[B]` | `tsc` type errors (Prisma client) | `--skipLibCheck` missing | Add `--skipLibCheck` to `tsc` |
| `[F]` | Blank page (SPA doesn't load) | Missing VITE_* or `vite.config.ts` absent | Check ARGs in Dockerfile and build-args in workflow |
| `[F]` | `VITE_API_URL` = `undefined` in JS | VITE_* not passed as build-arg | Check `build-args` in `docker/build-push-action` |
| `[F]` | 404 on React Router routes | nginx `try_files` not configured | `try_files $uri $uri/ /index.html` in nginx.conf |
| `[F]` | `Cannot access 'X' before initialization` | `treeshake.moduleSideEffects` + circular chunks | Remove `treeshake` and `manualChunks` from vite.config.ts |
| `[F]` | Container `unhealthy` (healthcheck fails) | Alpine resolves `localhost` as IPv6 | Use `127.0.0.1` in healthcheck |
| `[F]` | Vitest collecting Playwright E2E tests | `vitest.config.ts` without `e2e/` exclude | Add `exclude: ['e2e/**']` |
| `[B]` | `npx biome check .` fails on config files | Biome checks all files by default | Use `files.includes` in `biome.jsonc` to scope or fix the config files |
| `[B]` | Biome 2.x config error (`unknown key "ignore"`) | Biome 2.x removed `ignore` in favor of `includes` | Use `files.includes` instead of `files.ignore` in `biome.jsonc` |
| `[B]` | Migration reports "No pending migrations" but app crashes with missing column | Stale Docker image cache on self-hosted runner (`docker run` does not pull if tag exists locally) | `docker pull` the image before `docker run` in the migration step |
| `[F]` | Container nginx returns 403 | dist/ empty or not copied | Check `npm run build` and `COPY --from=build` in Dockerfile |
| `[S]` | `Missing script: "exec"` ao rodar tsc/playwright/openapi-typescript em workspace | Sintaxe inválida `npm run -w <ws> exec -- <cmd>` (não existe script `exec`; `exec` é subcomando de `npm`, não script de `package.json`) | Substituir por `npm exec -w <ws> -- <cmd>` |
| `[S]` | `ESLint couldn't find an eslint.config.(js\|mjs\|cjs) file` em workspace de monorepo | ESLint v9 removeu auto-detect de `.eslintrc.*`; flat config existe em outro workspace e **não** se propaga | Criar `eslint.config.js` por workspace que rode `eslint`. Pacotes Node-only: `globals.node`, sem `eslint-plugin-react-hooks` / `react-refresh` |
| `[S]` | `Cannot find package 'X' imported from /node_modules/<other-pkg>` em monorepo | devDep tem subtree em versões antigas que conflita com a raiz → npm aninha em `packages/<ws>/node_modules/X`; outras deps hoisted não acham via Node ESM resolution | Trocar por dep com subtree compatível (ex.: jsdom→happy-dom), OU declarar a dep no `package.json` raiz para forçar hoist, OU `overrides` para dedup das transitive deps conflitantes |
| `[F]` | Vitest pré-test fail (`Cannot find package 'jsdom'`) ou `TypeError: signal AbortSignal` em msw v2 | jsdom@20 não hoista em monorepo (subtree pesado) + injeta `AbortController` próprio incompatível com undici nativo do Node usado pelo msw v2 | Trocar para `happy-dom`: `npm i -D happy-dom -w <ws>` e `environment: 'happy-dom'` em `vitest.config.ts`. Subtree leve hoista limpo + AbortController nativo |
| `[B]` | `ERR_MODULE_NOT_FOUND` / `ERR_UNKNOWN_FILE_EXTENSION` num `.ts` de workspace dep ao rodar `node dist/index.js` | Sibling workspace exporta TS source (`main: ./src/index.ts`); `tsc` não inlina e `node` não carrega `.ts` | Rodar a imagem via `tsx src/index.ts` (lesson 37). NÃO repontar `exports` do shared p/ `dist` (quebra o Vite do frontend) |
| `[F]` | CI typecheck passa verde mas erros de tipo escapam pra produção | `tsc --noEmit` no `tsconfig.json` raiz com `files: []` + `references` checa ZERO arquivos | Usar `tsc -b --noEmit` (lesson 38) |
| `[S]` | Container não-root não grava em named volume (PDFs/storage `EACCES`) | Volume novo herda ownership do path na imagem; default root-owned | `mkdir -p <dir> && chown <user> <dir>` ANTES do `USER` na imagem (lesson 40) |
| `[S]` | `docker stop`/redeploy trava ~o grace period e mata o container com SIGKILL; sem logs de shutdown gracioso | CMD é um wrapper (`npx tsx`, `npm start`) como PID 1 que não repassa SIGTERM ao processo filho | `init: true` no service do compose (ou `tini` no ENTRYPOINT) — `cd-pipeline-pitfalls.md §8` (lesson 41) |
| `[S]` | Código entrou em `develop`/`main` sem rodar CI (push direto / merge não via PR) | `ci.yml` dispara só em `pull_request` → push direto a branch protegido escapa do gate | Adicionar trigger `push:` nos branches protegidos e/ou impor branch protection com required checks (lesson 44) |
| `[B]` | **(Django)** Deploy `wait healthy` nunca fica `healthy`; log do container `GET /healthz/ 400` (Host `127.0.0.1`) | `ALLOWED_HOSTS` só tem o domínio público; o Django rejeita o Host do healthcheck interno (400) | Incluir `localhost,127.0.0.1` no `ALLOWED_HOSTS` (lesson 52) — `django-backend.md` |
| `[B]` | **(Django)** admin/login 403 CSRF sob HTTPS atrás de proxy (API JWT funciona) | Django não enxerga HTTPS (TLS termina no nginx-proxy) → falta `CSRF_TRUSTED_ORIGINS` + `SECURE_PROXY_SSL_HEADER` | Setar ambos via env (lesson 53) — `django-backend.md` |
| `[S]` | GHCR push falha (`invalid reference`/`name unknown`) em org com Maiúscula | `github.repository_owner` preserva a caixa (`ChewieSoft`); GHCR exige lowercase | `owner=$(echo "${{ github.repository_owner }}" \| tr '[:upper:]' '[:lower:]')` (lesson 55) |
| `[S]` | `docker compose run` manual no host dá `unauthorized` no GHCR (deploy via runner OK) | Pacote GHCR é PRIVADO por padrão mesmo em repo público; o host não fez `docker login` | One-off de seed/manutenção via `docker exec` no container já rodando (sem re-pull), ou `docker login ghcr.io` antes (lesson 56) |
| `[S]` | Rollback automático "concluiu" e a versão quebrada continua servindo | Capturou `latest`/`staging` como tag anterior — mutáveis, e este mesmo deploy acabou de re-apontá-las p/ a imagem quebrada | Capturar só tags imutáveis `sha-*`; 1º deploy → `unknown` → pular e DIZER isso no summary (lesson 59) — `cd-verification-and-rollback.md` §1 |
| `[S]` | Run vermelho diz "rollback complete" e produção está fora | O rollback termina em `up -d`, cujo exit 0 significa "Docker aceitou", não "a versão anterior voltou a servir" | Re-smokar após o rollback; falha vira `::error::` explícito de produção fora (lesson 60) — `cd-verification-and-rollback.md` §2 |
| `[S]` | Um gate que você adicionou não aparece no log justo no deploy que falhou | `if: success()` é falso depois de qualquer step vermelho — gate de bom tempo, só verifica deploy que já estava bem | Posicionar gate secundário **depois** do rollback: marca o run vermelho sem derrubar a app (lesson 61) — `cd-verification-and-rollback.md` §3 |
| `[S]` | Container de backup `healthy` há meses e não existe um único dump | Healthcheck observa a porta HTTP de status da imagem, não o artefato; o `pg_dump` dentro dele falha toda noite | Gate no artefato: `gzip -t` + contar `COPY` + procurar registro conhecido (lesson 62) — `cd-verification-and-rollback.md` §4 |
| `[S]` | `pg_dump` autentica com usuário inexistente tipo `erp,zitadel` | `prodrigestivill/postgres-backup-local` só faz fan-out de lista CSV em `POSTGRES_DB`; em `POSTGRES_HOST`/`_USER`/`_PASSWORD` o valor é literal | Um service por cluster/credencial; tag do image ≥ versão do servidor (lesson 63) — `cd-verification-and-rollback.md` §5 |
| `[S]` | Promoveu staging→main, produção verde, e **staging passa a servir produção** | Merge fast-forward gera o MESMO `GITHUB_SHA` → a tag `sha-*` colide e o build de produção sobrescreve a imagem de staging no registry | Sufixo de ambiente na tag de imagem com env de build-time (`-prod`); backend genérico não precisa (lesson 64) — `cd-pipeline-pitfalls.md` §1c |
| `[F]` | nginx sobe `healthy`, smoke passa, e o browser bloqueia API/IdP sem citar CSP | Var ausente num `add_header` produz config **sintaticamente válida** com origem vazia; só `proxy_pass` faz o nginx recusar o parse | Fail-fast explícito no drop-in do entrypoint (`set -e` + `exit 1`); snippet NÃO pode morar em `/etc/nginx/templates/` (lesson 65) — `cd-pipeline-pitfalls.md` §1d |
| `[S]` | `vars.X` parece não existir porque a lista do environment está vazia | `vars` resolve org → repo → environment; a variável pode estar no nível do REPO e funcionar | Conferir os dois níveis antes de declarar bloqueio (lesson 66) — `cd-verification-and-rollback.md` §7 |
| `[S]` | Cota de GitHub Actions estourando; "otimizamos a duracao" e a fatura nao cede | Custo e por **JOB arredondado para cima ao minuto** (job de 51s = 1 min), nao por segundo — e jobs cobrados rodam em runner hospedado enquanto o self-hosted (gratis) fica ocioso so no `deploy` | `ci-cost-minutes.md` — medir por `/actions/runs/<id>/jobs` usando `runner_group_name` (**`billable.total_ms` do `/timing` pode vir `0`** e invalidar a medicao); mover `build-and-push`/`ci` p/ self-hosted; `paths-ignore` no `push` (em `pull_request` o filtro ve o diff inteiro do PR e so pula PR 100% documentacao) |
| `[S]` | Tres jobs "falharam" em ~3s, **sem steps**, `runner_name` vazio, e `gh run view --log-failed` diz `log not found` | **Sosia: confira antes o `.name` do run — se for o CAMINHO do arquivo, o workflow esta INVALIDO (licao 83), nao e cota.** **Nao e falha de codigo — o job nunca iniciou.** Bloqueio de cota/cobranca da conta; a mensagem so existe nas **annotations** do check-run | `ci-cost-minutes.md` §5 — `gh api /repos/<o>/<r>/check-runs/<job_id>/annotations`. Self-hosted **nao** passa pela cota: mover o gate p/ la faz o gate voltar a existir |
| `[S]` | Job movido p/ self-hosted quebra onde nunca quebrava: `Unable to locate executable file: yarn` no `setup-node`, ou `P1001`/`Connection refused` com o service container `healthy` | O runner hospedado e uma imagem curada (toolchain pre-instalada, rede propria); o self-hosted nao. `cache: 'yarn'` invoca o binario; e se o runner for **conteinerizado**, `127.0.0.1` e o loopback dele, nao o host onde `-p` publicou | `self-hosted-job-migration.md` — pre-voo por ordem de risco, e o padrao de **descobrir** a topologia (gateway via `/proc/net/route`) em vez de supor |
| `[S]` | O `cd-production.yml` (tag) ainda está em `ubuntu-latest` e o runner de produção é de **organização** — não dá para listá-lo (`403`) nem saber se tem Docker/buildx/yarn | Estrear runner desconhecido no dia da tag repete as rodadas da §6 com produção esperando; e a mudança só vale no **commit taggeado** | Rodar `ci`/`build-and-push` no runner **já provado** (o de staging, com o job `ci` copiado byte-idêntico — `diff` sem comentários) e deixar só o `deploy` no runner do ambiente; mesclar antes de cortar a tag — `self-hosted-job-migration.md` §5b |
| `[S]` | Passo do CD diz que um caminho do HOST não existe (`--env-file`, `-f`, `test -r`) com o arquivo intacto lá | Runner **conteinerizado**: bind mount resolve no daemon (host), mas flag de CLI resolve DENTRO do container — e o mesmo comando mistura os dois | `self-hosted-runner-docker.md` §12 — montar o **diretório** (não o arquivo: rotação troca o inode) `ro` no mesmo caminho; recriar o runner é o único caminho de deploy dele |
| `[S]` | Passo de bootstrap/seed roda **verde** aplicando uma declaração ANTIGA; o log diz `reuse`/`already exists` para tudo | `build:` com tag fixa (`:staging`) — `compose run`/`up` não reconstrói imagem cuja tag já existe (lição 29 pelo lado do *build*), e o input do passo costuma estar DENTRO da imagem | `cd-pipeline-pitfalls.md` §10 — `--build`; pipeline que empurra por `sha-*` ao registry é imune, o que constrói local com tag de ambiente não é |
| `[S]` | CD verde de ponta a ponta e a coisa que o deploy existia para fazer não aconteceu | O passo entrega **estado** (papéis, linhas de catálogo, dados migrados), e o smoke mede que o **serviço responde** — verdadeiro antes e depois dele | `cd-verification-and-rollback.md` §9 — asserção derivada da declaração; a saída do próprio passo costuma bastar, sem credencial nova |
| `[S]` | Captura de log vazia reportada como "sem erros" | Vazio é produzido por dois mundos: nada aconteceu, ou o sensor morreu (pipe quebrado, SSH caído, `grep` que não casa) | Disparar sonda deliberada e confirmar que ela aparece, antes de concluir pela ausência (lesson 67) — `cd-verification-and-rollback.md` §6 |
| `[S]` | Branch de ambiente (`staging`) criada e **nenhum deploy dispara** — `gh run list` só mostra runs antigos, e o silêncio parece "deploy ok" | Criar a branch a partir de um commit que já existe é um push com **zero arquivos alterados**; o `paths-ignore` pula quando TODOS casam, e com nenhum arquivo a condição é vacuamente verdadeira. `git commit --allow-empty` tem o mesmo defeito | `ci-cost-minutes.md` §3b — `workflow_dispatch` como válvula, somado **antes** de cortar a branch (o gatilho é lido do ref); depois `gh workflow run <wf> --ref <branch>` (lesson 81) |
| `[S]` | Job migrado p/ self-hosted reprova num teste de permissão (`chmod 0o500` → espera erro, recebe `null`) que sempre passou no hospedado | O runner é **root**, e root ignora bit de permissão (`CAP_DAC_OVERRIDE`). O código está certo; a premissa do teste é que caiu | Sensor que roda sempre com falha que root não contorna (raiz = arquivo → `ENOTDIR`) + `it.skipIf(uid === 0)` no de `EACCES`; provar como root em `node:22` e por sabotagem — `self-hosted-job-migration.md` §6a |
- | `[S]` | Job migrado p/ self-hosted reprova com `Test timed out in 5000ms` num teste que leva 0,5 s na sua máquina | O timeout default foi calibrado p/ desenvolvimento; no host compartilhado a suíte paralela disputa I/O (medido 25× mais lento) e há vizinhos a 90% do limite | Listar casos individuais do log, `testTimeout` com 3× o pior medido + timeout próprio da varredura; sonda de 7 s nos dois estados — `self-hosted-job-migration.md` §6b |
+ | `[S]` | Job migrado p/ self-hosted reprova com `Test timed out in 5000ms` num teste que leva 0,5 s na sua máquina | O timeout default foi calibrado p/ desenvolvimento; no host compartilhado a suíte paralela disputa I/O (medido 25× mais lento) e há vizinhos a 90% do limite | Listar casos individuais do log, `testTimeout` com 3× o pior medido + timeout próprio da varredura; sonda de 7 s nos dois estados. Se o erro for de `waitFor`/`findBy` (ex.: `Found multiple elements` com animação de saída), o limite é o `asyncUtilTimeout` do Testing Library (1 s), outro timeout — `self-hosted-job-migration.md` §6b |
| `[S]` | Deploy manual (sob bloqueio de cota) "concluído" com exit 0, e o container segue com o `Created` de antes | `docker compose run` sem `-T` comeu o stdin, que era o próprio script do `bash -s`; ou o `stdin=` do ansible ad-hoc em forma livre foi ignorado e o `docker login` não logou | Script por arquivo + `run -T … </dev/null`; ansible com args em **JSON**, provado com `wc -c`; prova sempre por `Created`/`Config.Image` — `ci-cost-minutes.md` §5a |
---
## Routing Table — Detailed References
| Problem Category | Reference |
| --------------------------------------------------------- | -------------------------------------------- |
| Shared infra (GHCR, network, SSL, runner via systemd) | `references/troubleshooting-shared.md` |
| **Self-hosted runner conteinerizado (myoung34/github-runner)** | `references/self-hosted-runner-docker.md` |
| Shared checklist (runner, GHCR, DNS) | `references/checklist-shared.md` |
| Backend troubleshooting (Zod, Prisma, Jest) | `references/troubleshooting-backend.md` |
| Backend checklist (secrets, tests, build) | `references/checklist-backend.md` |
| Jest test fix patterns | `references/test-fixes-backend.md` |
| Frontend troubleshooting (Vite, SPA, nginx) | `references/troubleshooting-frontend.md` |
| Frontend checklist (VITE_*, Dockerfile, CD) | `references/checklist-frontend.md` |
| **CD pipeline pitfalls (build-time vs runtime, operator clones, `--profile run` reconcile, `compose run` orphans poisoning reverse-proxy upstream)** | `references/cd-pipeline-pitfalls.md` |
| **Backend Django (gunicorn, collectstatic/WhiteNoise, ALLOWED_HOSTS healthcheck-400, CSRF/proxy-SSL, migrate one-off, two-origin CORS)** | `references/django-backend.md` |
| **Custo de minutos do Actions (medir por job, self-hosted como alavanca, paths-ignore, cache frio; deploy manual enquanto o bloqueio dura)** | `references/ci-cost-minutes.md` |
| **Migrar um job cobrado para o self-hosted (toolchain ausente, rede do service container, `container:` e versao do runner; qual runner p/ o `cd-production` de tag; e o pos-migracao: divida do repo revelada — runner root, timeout de teste —, `.env` ausente no CI, snapshot preso a env, job de gate que vira tautologico; build sem `setup-buildx`/cache `gha`)** | `references/self-hosted-job-migration.md` |
| **CD verification & rollback (tag imutável p/ rollback, re-smoke, ordem dos gates, prova do backup pelo artefato, provar o sensor, `cmd \| tail` que joga fora o exit code, `vars` repo vs environment)** | `references/cd-verification-and-rollback.md` |
> **Trigger pra `cd-pipeline-pitfalls.md`**: você está num cutover de produção (ou hotfix) e o sintoma envolve uma divergência entre camadas — secret atualizado mas container ainda com valor antigo, frontend buildado contra URL errada, manual `docker compose run` derrubando containers de outros serviços, OU **401 inconsistente em produção com token sabido válido** (split entre 200/401 sob hits paralelos). Sintomas-chave: (§1) SPA com 404 em todas as chamadas API após login funcionar — VITE_* base URL drift; (§2) "operator clone" do repo no host com versão stale do compose — e o **§2a**, que o qualifica: o drift ali é inerte para a aplicação (o CD recheca do workspace do runner) e é o **único** caminho de deploy do próprio `runner`, ou path canônico do runbook não existe no host (deploy real é via runner workspace); (§3) `docker compose --profile X run` derrubando containers running; (§4) `compose run` orphan herdou `VIRTUAL_HOST` do serviço e foi registrado no upstream pool do nginx-proxy/Traefik — round-robin manda ~50% das requests pra container stale com config velha. Diagnóstico canônico §4: 20 hits paralelos com mesmo token → split de status codes = upstream pool poisoned.
> **Trigger pra `self-hosted-runner-docker.md`**: presença de `infra/docker/runner/Dockerfile` (ou similar) com `FROM myoung34/github-runner` no projeto, OU `docker-compose.*.yml` com serviço cujo `image:`/`build:` referencia esse runner conteinerizado. Sintomas-chave: container em loop de restart com exit 0/2, logs com "Configuring → Settings Saved → fim", "Cannot configure the runner because it is already configured", build falhando em `gpg --dearmor`, ou `gh api .../actions/runners` mostrando label `default` em vez da configurada. **§7 cobre o cenário deadlock-em-prod**: deploy queued + `gh-runner` em `Restarting` + log `404 /actions/runner-registration` = `secrets.RUNNER_REGISTRATION_TOKEN` estática expirou e o equilíbrio "compose detecta no-diff e não recria" quebrou (host restart, OOM, ephemeral ciclando). Recovery exige 3 passos coordenados: rotacionar GH secret + deletar registro fantasma + subir runner via `compose -p <project> up -d --no-deps runner` (não `docker run` — sem labels compose, próximo CD `up` conflita). Fix permanente: token gerado a quente no workflow OU migrar pro compose centralizado de runners com `ACCESS_TOKEN` (PAT). **§8/§9 cobrem dois crashloops ORTOGONAIS ao token** (mordem até runners em ACCESS_TOKEN): §8 = `Runner version vX is deprecated and cannot receive messages` (binário velho — `compose pull` + ligar auto-update; cuidado: `DISABLE_AUTO_UPDATE` desliga com QUALQUER valor não-vazio, até `"0"`); §9 = `Failed to create a session. The runner registration has been deleted from the server` (reuso de config ressuscita credencial morta — `docker volume rm <config-volume>`). Isolation key dos três: o log (`404 registration`=§7, `registration has been deleted`=§9, `version deprecated`=§8). **Mas podem EMPILHAR** — um único deploy queued pode exigir §9 → PAT 401 → §8 em sequência, cada fix desmascarando o próximo; ver §10 (descascar em ordem, re-ler os logs após cada passo). **§8b é a variante SILENCIOSA do §8** — runner `online`, `healthy`, `RestartCount` baixo e mudo (migração para o Broker): nenhuma das assinaturas acima aparece, e o sensor é o filesystem (`ls -d /actions-runner/bin.*`), não o log; o §8a explica por que o auto-update não salva esse desenho. **§11 é o complemento proativo**: o deploy self-hosted fica `queued` em silêncio (o `timeout-minutes` não conta em fila, só após pickup), então §11 cobre a **detecção** — preflight gate (precisa PAT admin; `GITHUB_TOKEN` não lista runners) + watchdog agendado + **camada C, currency de versão** — a única das três que pega o §8b, porque presença e deploy-preso medem efeitos e ela mede a causa (cheque status de JOB, não de run; `schedule` só roda do branch default).
> **Trigger pra `cd-verification-and-rollback.md`**: você está desenhando (ou depurando) a parte do CD que **prova** que o deploy deu certo — rollback, smoke, gates de saúde — ou desconfia de um sinal verde. Tema unificador: **o sensor disse sim enquanto a coisa que ele representa era falsa**, ou o sensor nunca rodou e o silêncio passou por sucesso. Sintomas-chave: (§1) rollback "concluiu" e a versão quebrada segue servindo — capturou tag mutável; `previous_tag=unknown` no 1º deploy é correto, mas precisa aparecer no summary; (§2) run vermelho diz "rollback complete" e produção está fora — `up -d` retornando 0 não prova que a versão anterior boota, fica healthy e responde; (§3) um gate `if: success()` não aparece no log **justo** no deploy que falhou — e o posicionamento deliberado **depois** do rollback é o que faz uma falha marcar o run vermelho SEM derrubar a aplicação; (§4) container de backup `healthy` há meses sem um único dump, porque o healthcheck olha a porta HTTP de status e não o artefato — prova real é `gzip -t` + contar `COPY` + registro conhecido (tamanho é proxy fraco: um dump só com DDL é grande e inútil); (§5) `pg_dump` autenticando como `erp,zitadel`; (§6) captura de log vazia lida como "limpo"; (§7) `vars.X` que parece ausente porque só o environment foi consultado. Complementa o `cd-pipeline-pitfalls.md`, que trata de camadas divergentes; aqui o assunto é a **evidência**.
> **Trigger pra `ci-cost-minutes.md`**: a restricao e a **cota de minutos**, nao um pipeline quebrado — "estamos no limite do Actions", "como economizar", "rodar CI so em producao". Tema: o custo **nao** e proporcional ao tempo de execucao. Sintomas-chave: (§1) otimizaram a duracao dos jobs e a fatura nao cedeu — o GitHub arredonda **cada JOB para cima ao minuto**, entao job de 51s custa 1 min e paralelizar em dois jobs ADICIONA um minuto; (§2) `/actions/runs/<id>/timing` reportando `billable.UBUNTU.total_ms: 0` com `run_duration_ms` de minutos — numero enganoso que faz concluir "nao gastamos nada"; medir por `/actions/runs/<id>/jobs` com `runner_group_name` (`GitHub Actions`=cobrado, `Default`=self-hosted/gratis), e lembrar que `gh api /repos/.../actions/runners` **nao lista runner de organizacao** (lista vazia nao prova ausencia — confirme pelo `runner_name` de um deploy que concluiu); (§3) o mesmo SHA testado tres vezes (PR → push develop → tag) e `paths-ignore` que so paga no `push` (em `pull_request` o filtro avalia o diff inteiro do PR, entao so pula um PR 100% documentacao); (§4) o que PARECE economia e nao e — a composite action da licao 43 cura *drift*, o job continua rodando e a economia e zero; e `cache: 'yarn'` configurado nao garante cache quente (entradas expiram em 7 dias; confira `/actions/cache/usage`). Complementa a licao 7 (lint local antes do push), que e a mesma ideia vista pelo lado do desperdicio.
> **Trigger pra `django-backend.md`**: o backend é **Django/Python** (`manage.py`, `requirements.txt`, gunicorn) — não Node/Prisma. Sintomas-chave: deploy `wait healthy` nunca fica `healthy` com healthcheck `GET /healthz/ 400` (ALLOWED_HOSTS sem `127.0.0.1`/`localhost`); admin 403 CSRF sob HTTPS atrás de nginx-proxy (falta `CSRF_TRUSTED_ORIGINS`/`SECURE_PROXY_SSL_HEADER`); estáticos do admin 404 sob gunicorn (falta WhiteNoise + `collectstatic` em build); `python:slim` sem curl no HEALTHCHECK (usar `python -c urllib`); migração one-off `manage.py migrate --noinput`; two-origin SPA↔API via CORS (`CORS_ALLOWED_ORIGINS` no Django + `VITE_API_URL` build-arg no front).
---
## Lessons Learned (Summary)
| # | Tag | Lesson | Context |
|---|-------|--------|---------|
| 1 | `[S]` | GHCR auth: sudo vs user context | `~/.docker/config.json` is per-user |
| 2 | `[S]` | nginx-proxy network name varies by installation | Check with `docker network ls` |
| 3 | `[S]` | Secret URLs must include `https://` | Zod `z.string().url()` rejects without protocol |
| 4 | `[S]` | Port mapping unnecessary with nginx-proxy | No `ports:` in staging/prod compose |
| 5 | `[S]` | DNS must point to the server IP | Let's Encrypt needs HTTP-01 challenge |
| 6 | `[S]` | Concurrency groups block deploys | `cancel-in-progress: false` queues |
| 7 | `[S]` | Lint locally before pushing to develop | Push triggers CD; errors waste cycles |
| 8 | `[S]` | Re-trigger without `workflow_dispatch` | `gh run rerun`. `git commit --allow-empty` only fires a workflow that has no `paths-ignore` — an empty push is skipped by the filter (lesson 81); add `workflow_dispatch` instead |
| 9 | `[B]` | `bitnami/postgresql` image discontinued | Use `postgres:17` with `POSTGRES_USER` |
| 10 | `[B]` | `--skipLibCheck` required in build | Prisma client generates conflicting types |
| 11 | `[B]` | Prettier not installed as dependency | Must be an explicit devDependency |
| 12 | `[B]` | Zod validation fails in CI | All vars from `src/env.ts` in the test step |
| 13 | `[B]` | `DATABASE_URL` with wrong prefix | Project's Zod requires `postgres://` |
| 14 | `[B]` | Zod vars in Generate .env of CD | Update CI and CD when adding a var in Zod |
| 15 | `[B]` | `VIRTUAL_PORT` required for port ≠ 80 | nginx-proxy default is 80 |
| 16 | `[B]` | `continue-on-error` is a workaround | Use only temporarily |
| 17 | `[B]` | `server.ts` guard for `NODE_ENV=test` | Prevents `EADDRINUSE` in tests |
| 18 | `[F]` | VITE_* are build-time, not runtime | Env vars in the nginx container have no effect |
| 19 | `[F]` | Docker image is environment-specific | Staging and prod are different images |
| 20 | `[F]` | `build-and-push` needs `environment:` | To access VITE_* secrets as build-args |
| 21 | `[F]` | No `VIRTUAL_PORT` for nginx | nginx listens on port 80 (default) |
| 22 | `[F]` | Healthcheck Alpine: `127.0.0.1` | `localhost` may resolve to `::1` (IPv6) |
| 23 | `[F]` | `vite.config.ts` must be versioned | Without it, bundle without React plugin → blank page |
| 24 | `[F]` | Vitest collecting Playwright E2E tests | `vitest.config.ts` with `exclude: ['e2e/**']` |
| 25 | `[F]` | `treeshake.moduleSideEffects` + circular chunks | Remove custom treeshake and manualChunks |
| 26 | `[S]` | GHCR login required in deploy job | `docker/login-action@v3` before pull (both projects) |
| 27 | `[B]` | Biome checks all files by default | Use `files.includes` in `biome.jsonc` to limit scope to `src/` or fix config files |
| 28 | `[S]` | First deploy requires workflows on `develop` branch | CD Staging triggers on push to `develop` — workflows must be on that branch before the first push |
| 29 | `[B]` | `docker run` does not auto-pull if the tag exists locally on self-hosted runners | Always `docker pull <image>` before `docker run <image>` in migration steps — stale cache causes "no pending migrations" while the app expects new schema |
| 30 | `[S]` | `npm run -w <ws> exec --` é sintaxe inválida em monorepo npm | `exec` não é script de `package.json`; usar `npm exec -w <ws> -- <cmd>`. Falha cedo (`Missing script: "exec"`) e mascara steps subsequentes |
| 31 | `[S]` | ESLint v9 flat config é per-workspace, não herda | Cada workspace que rode `eslint` precisa do próprio `eslint.config.{js,mjs,cjs}` — bump pra v9 num workspace não dá config aos siblings |
| 32 | `[S]` | devDep com subtree em versões antigas não hoista em monorepo npm | npm aninha o subtree em `packages/<ws>/node_modules/X`, fora do alcance da resolução Node ESM partindo de outra dep hoisted. Diagnóstico: comparar `node_modules/X` (raiz) vs `packages/<ws>/node_modules/X` no lock |
| 33 | `[F]` | vitest 3 + msw v2 + jsdom esconde 2 bugs latentes | Hoisting (jsdom@20 não hoista) + AbortSignal mismatch (jsdom injeta primitivas próprias incompatíveis com undici nativo). `happy-dom` resolve ambos: subtree leve hoista limpo + AbortController nativo do Node |
| 34 | `[S]` | `compose run --rm` orphan + nginx-proxy = upstream pool poisoning | One-off `compose run` herda `VIRTUAL_HOST` do serviço; se `--rm` falha (CI cancel / OOM / daemon restart), órfão fica registrado pelo docker-gen no upstream pool e recebe round-robin com config stale. `up -d --remove-orphans` NÃO cobre (mesmo serviço, suffix-hash). Fix: `-e VIRTUAL_HOST= -e LETSENCRYPT_HOST=` no `compose run` + step pre-rolling `docker rm -f` em `*-run-*`. Diagnóstico: 20 hits paralelos = split de status codes |
| 35 | `[S]` | `secrets.RUNNER_REGISTRATION_TOKEN` estática é equilíbrio frágil — chicken-and-egg quando quebra | Registration tokens vencem em 1h; design só funciona porque `compose up` detecta no-diff entre deploys e pula recriação do `runner` service. Qualquer evento que force re-registro (host restart, OOM, ephemeral ciclando) → `config.sh` com token vencido → 404 → crashloop. Deploy fica queued sem runner, runner não sobe sem deploy. Recovery: rotacionar GH secret + apagar registro fantasma + subir via `compose -p <project> up -d --no-deps runner` (mesmo token e labels match). Fix permanente: token a quente no workflow OU PAT no compose centralizado |
| 36 | `[S]` | GHCR `TLS handshake timeout` vs `unauthorized` — não são o mesmo bug | `unauthorized` = TLS completou, credencial rejeitada (rotacionar PAT). `TLS handshake timeout` = TCP conectou mas handshake não completou — credencial é irrelevante. Isolation key: se `build-and-push` em `ubuntu-latest` passa mas `deploy` em `self-hosted` falha, GHCR está saudável → problema é rede do host runner (MTU em VPN/overlay drops Certificate frames; ou proxy corporativo de TLS inspection). Fix imediato: bash retry wrapper no step de login (3x, backoff 10s/20s) — absorve flake transiente. Fix root: `mtu: 1400` em `/etc/docker/daemon.json` + restart docker. `docker/login-action@v3` não tem retry nativo |
| 37 | `[B]` | Monorepo cujos workspaces shared exportam TS source → imagem roda via `tsx`, não `node dist/` | Pacotes `@scope/shared-*` com `main: ./src/index.ts` (TS cru, convenção de import com extensão `.js`): o `dist/` compilado morre em runtime com `ERR_MODULE_NOT_FOUND`/`ERR_UNKNOWN_FILE_EXTENSION` ao resolver o `.ts` do sibling — `tsc` não inlina workspace deps e `node` puro não carrega `.ts`. Fix: o estágio runtime roda `tsx src/index.ts` (esbuild), igual ao dev. NÃO repontar o `exports` do shared p/ `dist` — quebra o Vite/bundler do frontend que consome o source. Ver `troubleshooting-backend.md` |
| 38 | `[F]` | `tsc --noEmit` é VAZIO em tsconfig com project references (`files: []`) — gate de CI falso | O `tsconfig.json` raiz padrão de Vite/Lovable tem `"files": []` + `references` p/ `tsconfig.app.json`. `tsc --noEmit` então checa ZERO arquivos e sai 0 — typecheck verde-fake. Usar `tsc -b --noEmit` p/ checar de fato os projetos referenciados. Corolário: introduzir esse gate num projeto que só rodava `vite build` (esbuild, sem typecheck) revela um backlog de erros de tipo latentes. Ver `troubleshooting-frontend.md` |
| 39 | `[B]` | Workspace importa sibling NÃO declarado (só hoist resolve) → scoped `npm ci -w` quebra no Docker | Ex.: o frontend importa `@scope/shared-api-types` sem declará-lo no `package.json`; funciona local pelo hoist do workspace, mas `npm ci -w @scope/frontend` no build Docker não cria o symlink → Vite build falha em resolver. Fix: `npm ci` cheio no estágio builder (descartado — só `dist/` vai à imagem final, tamanho irrelevante). Distinto da lesson 32 (subtree não-hoistável) |
| 40 | `[S]` | `USER node` + named volume novo = write falha sem `mkdir`+`chown` na imagem ANTES do `USER` | Docker inicializa um named volume novo a partir do conteúdo **e da ownership** do path na imagem. Se o dir não existe (ou é root-owned), o volume monta root-owned e o user não-root não grava (PDFs/storage → EACCES). Fix: `RUN mkdir -p /app/storage && chown -R node:node /app/storage` antes do `USER node`. (Só vale p/ named volumes — bind mounts não copiam ownership.) |
| `[S]` | ~50% das requests autenticadas retornam 401 mesmo com JWT comprovadamente válido (200 quando replay direto via curl) | Container órfão de `compose run --rm` antigo (ex.: `prisma migrate deploy` que não disparou `--rm` por CI cancelado / OOM) ainda Up, herdou `VIRTUAL_HOST` do serviço, registrado pelo docker-gen no upstream pool do nginx-proxy. Round-robin envia ~50% pra config stale. Confirmação: 20 hits paralelos com mesmo token → split de status codes. Ver `cd-pipeline-pitfalls.md §4` |
| 41 | `[S]` | Wrapper como PID 1 no container engole SIGTERM → sem shutdown gracioso | CMD `npx tsx …`/`npm start`/`npm run …` deixa o `npx`/`npm` como PID 1; ele forka o processo real e NÃO repassa SIGTERM. No `docker stop`/redeploy o filho nunca recebe o sinal → SIGKILL após o grace period (sem drain de conexões, sem `$disconnect()` do Prisma). Fix: `init: true` no service do compose (um init tipo tini reapeia zumbis e repassa sinais), ou `ENTRYPOINT ["tini","--"]` na imagem. Ver `cd-pipeline-pitfalls.md §8` |
| 42 | `[B]` | Corolário da 37: `tsx`/`prisma` em `dependencies` deixam a imagem de runtime usar `--omit=dev` | Se as ferramentas que o runtime/migrate precisam (`tsx`, Prisma CLI) ficam em `devDependencies`, não dá p/ enxugar a imagem — `npm ci --omit=dev` as removeria e quebraria o boot/migrate. Movendo-as p/ `dependencies`, o estágio runtime roda `npm ci --omit=dev` e o test tooling pesado (vitest, testcontainers, supertest, typescript) sai da imagem. O builder segue com `npm ci` cheio (lesson 39) p/ generate/typecheck. Verificar com `docker run … ls node_modules`. Ver `troubleshooting-backend.md` |
| 43 | `[S]` | CI gate duplicado entre `ci.yml` e re-gate de `cd-staging.yml` → extrair composite action | Os mesmos passos (setup-node + install + lint/typecheck/test) copiados nos dois workflows driftam. Extrair `.github/actions/<gate>/action.yml` (composite) como fonte única. Pegadinhas: `uses: ./.github/actions/…` exige `actions/checkout` ANTES no job chamador (o composite NÃO faz checkout); os nomes de job permanecem contratuais p/ required checks; validar com `actionlint`. Ver `troubleshooting-shared.md §10` |
| 44 | `[S]` | CI só com trigger `pull_request` deixa push direto a branch protegido escapar do gate | Se o `ci.yml` dispara só em `pull_request`, um `push` direto a `develop`/`main` (admin, ou branch protection sem "require status checks") NÃO roda lint/typecheck/test. Fix: trigger `push:` nos branches protegidos (gate roda no merge) E/OU impor branch protection com required checks. `staging` fica de fora se já houver um `cd-staging.yml` com CI gate próprio. Ver `checklist-shared.md §5` |
| 45 | `[S]` | Pinar imagem base por digest: descobrir o `@sha256:` sem pull cheio | Aplique às imagens do app (`node`, `nginx`, `postgres`). Tag flutuante (`node:22-alpine`, `postgres:17`) re-resolve no rebuild → não reprodutível. Descobrir o digest sem baixar a imagem: `docker buildx imagetools inspect <img> \| grep Digest` (lê só o manifest); depois `FROM img:tag@sha256:…` (compose: `image: img:tag@sha256:…`). **Exceção: a imagem do RUNNER** — pinar por digest sem cadência de bump é contraproducente; o GitHub força currency e a versão congela até deprecar (lição 49 / §8a). Ver `self-hosted-runner-docker.md` |
| 46 | `[S]` | §7 (registration token chicken-and-egg) RECORRE porque o recovery não é cura — o fix durável é migrar p/ `ACCESS_TOKEN` (PAT) in-place | O recovery (rotacionar token + recriar) compra só ~1h, e `EPHEMERAL:false` NÃO previne: o entrypoint custom limpa `.runner` e re-registra a cada restart, então qualquer restart bate no token vencido (RestartCount em milhares). Fix: manter o runner no compose do produto e trocar `RUNNER_TOKEN: ${...}` → `ACCESS_TOKEN: ${RUNNER_ACCESS_TOKEN:-}` + `RUNNER_SCOPE: repo`; entrypoint passa a aceitar `ACCESS_TOKEN` OU `RUNNER_TOKEN`; PAT vive SÓ no `.env` persistente do host (nunca GH secret). Pegadinhas: **`gh` NÃO cunha PAT** (só web UI; `gh auth token` com escopo `repo` serve de stopgap mas acopla ao login); valide o PAT (`GH_TOKEN=… gh api .../registration-token`) ANTES de recriar; prove a cura com `docker restart` (re-registra sem 404). **ACCESS_TOKEN cura SÓ o §7** — não imuniza contra §8 (binário deprecado) nem §9 (config stale), que são ortogonais ao modelo de credencial. Ver `self-hosted-runner-docker.md` §7 → "Migração ACCESS_TOKEN in-place" |
| 47 | `[S]` | Runner crashloopa com `Runner version vX is deprecated and cannot receive messages` mesmo conectando OK | Distinto do §7 (token) e do §9 ("registration deleted"): o runner **registra, conecta e lista jobs**, então o GitHub recusa entregar trabalho porque o binário foi deprecado. Causa: imagem `:latest` baixada uma vez e nunca re-puxada + auto-update desligado → binário apodrece. Tell: `Up <segundos>` mas `RestartCount` milhares; status pisca online/offline. Fix imediato: `docker compose pull` + `up -d --force-recreate`; durável: ligar auto-update. O job `queued` é pego AUTOMATICAMENTE quando o runner volta online (sem `gh run rerun`). Ver `self-hosted-runner-docker.md` §8 |
| 48 | `[S]` | `DISABLE_AUTO_UPDATE` é footgun — qualquer valor não-vazio (até `"0"`/`"false"`) DESLIGA o auto-update | O entrypoint do `myoung34` faz `[ -n "${DISABLE_AUTO_UPDATE}" ]` → presença de QUALQUER string ativa `--disableupdate`. Para LIGAR o auto-update (e evitar a lição 47), **REMOVER a variável** do compose, não setá-la `"0"`. Aplicar exige recriar o container (`up -d --force-recreate`); confirmar com `docker exec <runner> printenv DISABLE_AUTO_UPDATE` (não deve retornar nada). Ver `self-hosted-runner-docker.md` §8a |
| 49 | `[S]` | Pinar a imagem do RUNNER por digest é contraproducente sem cadência de bump (exceção à lição 45) | A lição 45 (pin por digest) vale p/ `node`/`nginx`/`postgres`, mas o GitHub **força currency de versão** do runner: um digest congelado deprecia em ~1–2 meses e cai na lição 47 (§8). Escolha consciente: (a) `:latest` + auto-update ligado, OU (b) pin por digest + **cron/rotina mensal de `docker compose pull`**. Reuso de config (`CONFIGURED_ACTIONS_RUNNER_FILES_DIR` + named volume) ressuscita credencial morta após o GitHub apagar registro de runner offline demais → §9; fix é `docker volume rm <config-volume>` (limpa estado LOCAL, distinto do §6). Ver `self-hosted-runner-docker.md` §8a / §9 |
| 50 | `[S]` | §7/§8/§9 podem EMPILHAR no mesmo runner — cada fix desmascara o próximo (um único deploy queued exigiu os três em sequência) | O modelo "case o log → aplique o único fix" é insuficiente quando as falhas se sobrepõem. `docker volume rm` (§9, config morta) **desmascara** um `ACCESS_TOKEN` expirado — invisível enquanto o reuso de config nunca exercia o PAT — e o PAT novo **desmascara** o binário deprecado (§8). Descascar de baixo p/ cima (config → token → binário) e **re-ler os logs após cada fix** (a assinatura muda). Triagem: `gh api …/actions/runners` lista os offline também → runner **ausente** (não "offline") = registro apagado/§9. O PAT é host-wide (um expira → derruba todos os runners do host; um PAT novo + `up -d --force-recreate` conserta todos). Ver `self-hosted-runner-docker.md` §10 |
| 51 | `[S]` | Deploy self-hosted `queued` é SILENCIOSO — `timeout-minutes` não limita tempo em fila; §7–§10 ensinam a consertar, não a SER ALERTADO | O `timeout-minutes` de um job self-hosted só começa **após** um runner pegar o job — sem runner, fica `queued` sem ❌/timeout/e-mail (num incidente passou ~5 semanas despercebido; o site seguia no ar com a imagem antiga). Detecção em 2 camadas, ambas em `ubuntu-latest`: (a) **preflight gate** antes do `deploy` que lista `/actions/runners` e falha rápido se não há runner online com o label — gotcha: **`GITHUB_TOKEN` NÃO lista runners** (exige admin), precisa de PAT `Administration: Read`; degrade p/ no-op sem o secret e **fail-open em erro de PAT** (com `set -e` + `ONLINE=$(gh api …)` o gate bloqueia TODO deploy se o PAT expira/rotaciona, com msg enganosa "no runner" — use `if ! ONLINE=$(…)` e `exit 0` no erro de API); (b) **watchdog** agendado (cron) que detecta deploy preso — gotchas: cheque status de **JOB** (o run fica `in_progress` com o job `queued`; `?status=queued` no run erra) e **`schedule` só roda do branch default**. É detecção, não cura (root-cause segue host-side). Ver `self-hosted-runner-docker.md` §11 |
| 52 | `[B]` | (Django) `ALLOWED_HOSTS` sem `127.0.0.1`/`localhost` → healthcheck interno 400 → container nunca fica `healthy` | O HEALTHCHECK bate em `http://127.0.0.1:8000/healthz/` com Host `127.0.0.1`; o Django responde **400** a Host fora de `ALLOWED_HOSTS`, então o `wait-healthy` do CD estoura **mesmo com login/pull/migrate OK**. Isolation key: log do container mostra `GET /healthz/ 400`. Fix: `ALLOWED_HOSTS=<dominio>,localhost,127.0.0.1`. Ver `django-backend.md` |
| 53 | `[B]` | (Django) admin/sessão dá 403 CSRF sob HTTPS atrás de nginx-proxy (a API JWT funciona) | O TLS termina no proxy → o Django não enxerga HTTPS e rejeita o POST do admin/login por CSRF. Setar `SECURE_PROXY_SSL_HEADER=('HTTP_X_FORWARDED_PROTO','https')` + `CSRF_TRUSTED_ORIGINS=https://<dominio>`. A SPA via JWT não precisa — só admin/sessão. Ver `django-backend.md` |
| 54 | `[B]` | (Django) imagem prod: gunicorn + `collectstatic` em build + WhiteNoise; healthcheck sem curl; migrate one-off | `collectstatic --noinput` em build-time exige `SECRET_KEY` (DEBUG=False) mas NÃO acessa DB → passe um `SECRET_KEY` dummy só no `RUN`. **WhiteNoise `CompressedStaticFilesStorage`** (NÃO `Manifest…`, que quebra dev DEBUG=True sem collectstatic) serve o estático do admin sob gunicorn. `python:slim` não tem curl/wget → HEALTHCHECK via `python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz/')"`. Migração é one-off (`compose run --rm backend python manage.py migrate --noinput`), análogo ao `prisma migrate deploy`. Ver `django-backend.md` |
| 55 | `[S]` | GHCR push falha em org com Maiúscula (`ChewieSoft`) | `github.repository_owner` preserva a caixa, mas paths de imagem GHCR têm de ser **lowercase**. Lowercale antes de montar a tag: `owner=$(echo "${{ github.repository_owner }}" \| tr '[:upper:]' '[:lower:]')` → `ghcr.io/$owner/<img>` |
| 56 | `[S]` | `docker compose run` manual no host dá `unauthorized` no GHCR, mas o deploy via runner funciona | Pacotes GHCR nascem **privados** mesmo em repo público; o deploy puxa porque o runner faz `docker login`, mas um one-off manual no host não está logado. Para seed/manutenção use `docker exec` no container **já rodando** (sem re-pull do GHCR), ou `docker login ghcr.io` antes do `compose run`/`pull` |
| 57 | `[S]` | Commit só de documentação redeploya à toa — `paths-ignore` no `push` evita isso; em `pull_request` ele quase não paga | `on.push.paths-ignore: ['**.md','docs/**']` pula o CD em mudanças só de doc — num repo com documentação viva (ADRs, specs, artefatos de QA) metade dos commits pode ser só `.md`, cada um subindo service container e suíte inteira. O filtro só pula quando **TODOS** os arquivos avaliados batem: no `push` são os arquivos daquele push; em `pull_request` é o diff **inteiro** do PR (`base...head`), então só um PR 100% documentação é pulado — a alavanca real está no `push`. Não usar em workflow disparado por **tag**. Ver `ci-cost-minutes.md` §3b |
| 58 | `[S]` | O "fix durável" §7 (migrar p/ PAT dedicado) TEM PRAZO — um PAT com expiração recai no §10 PAT-401 na cadência de vencimento (visto RECORRER ~1 mês depois) | Migrar `RUNNER_TOKEN`→`ACCESS_TOKEN` cura o crashloop de *registration token* (`404`), mas o PAT gravado no `.env` é ele próprio expirável: quando vence, mesmo crashloop com log DIFERENTE — `curl (22) 401` / `Invalid configuration provided for token` ao `Obtaining the token` (`RestartCount` milhares) + deploy `queued` silencioso (§11). **Standalone** (não só empilhado no §10) e, no modelo **per-produto** (cada app com seu `runner`+`.env` em `infra/*/`), **isolado a um runner** — os outros do host seguem UP (o "host-wide" do §10 vale só p/ compose CENTRALIZADO). Recovery-higiene: o log do runner já é prova definitiva — NÃO re-extraia+`curl`e o secret (classifier de agente bloqueia como exfiltração); valide o PAT CANDIDATO via `gh api …/registration-token` (sessão do gh, sem tocar no secret cru) e grave via **stdin**. Fix de fato durável: PAT **sem expiração** OU lembrete/cron de rotação. Ver `self-hosted-runner-docker.md` §10a |
| 59 | `[S]` | Rollback só pode mirar tag **imutável** — `latest` foi re-apontada por este mesmo deploy | Capturar como "versão anterior" a tag servida pelo container (`docker inspect --format '{{.Config.Image}}'`) e **aceitar só `sha-*`**: `latest`/`staging` são mutáveis e o deploy que está falhando acabou de movê-las para a imagem quebrada — rollback p/ `latest` volta para a falha. No 1º deploy não há anterior (`unknown`) e pular é o certo, mas **diga isso no `$GITHUB_STEP_SUMMARY`**: "rollback pulado" e "rollback rodou" são indistinguíveis num run vermelho, e a recuperação passa a ser manual. Pré-requisito: o build tem de publicar `sha-<short>` além das tags móveis. Ver `cd-verification-and-rollback.md` §1 |
| 60 | `[S]` | Rollback que termina no `up -d` não prova nada — re-smoke ou não houve rollback | `up -d` saindo 0 significa que o Docker **aceitou o pedido**, não que a imagem anterior subiu, ficou healthy e voltou a atender. Um rollback que restaura o serviço e um que não restaura são indistinguíveis nesse ponto — o operador descobre pelo usuário. Fix: após o `up -d` de volta, esperar healthy e repetir o MESMO smoke; sucesso vira `::warning::` (o deploy ainda falhou, alguém precisa olhar) e falha vira `::error::` com "produção está fora". Migrations NÃO voltam (`migrate deploy` não tem down) → a versão anterior roda contra o schema novo; aditivo sobrevive, destrutivo não. Ver `cd-verification-and-rollback.md` §2 |
| 61 | `[S]` | `if: success()` é gate de bom tempo — não roda justamente no deploy que falhou | Condição de step é avaliada contra o status do job até ali, então `if: success()` fica falso após qualquer step vermelho: o gate que você somou "por segurança" verifica só os deploys que já estavam bem. O uso não-óbvio é o inverso — posicionar um gate secundário (saúde de backup, scrape de métrica) **depois** do step de rollback faz o `if: failure()` do rollback já ter sido avaliado, então a falha do gate **marca o run vermelho sem derrubar a aplicação**. Costuma ser a semântica certa: a app está no ar, o backup é que não está, e rolar a app para trás não conserta nada. Ver `cd-verification-and-rollback.md` §3 |
| 62 | `[S]` | Gate de backup tem de olhar o ARTEFATO — healthcheck de container de backup mede a coisa errada | O healthcheck observa algo adjacente ao trabalho (tipicamente a porta HTTP de status que a imagem expõe), não o arquivo que o job deveria produzir. O container fica genuinamente `healthy` enquanto o `pg_dump` dentro dele falha toda noite — visto 3 meses com zero dumps, e o banco desprotegido era o do IdP que autenticava todos os outros serviços do host. Prova real: forçar um ciclo (`docker exec <ctr> /backup.sh`), `gzip -t` (integridade), contar `^COPY public` (dados, não só schema) e procurar um registro conhecido. **Tamanho é proxy fraco** — um dump só com `CREATE TABLE` é grande, bem formado e inútil. Ver `cd-verification-and-rollback.md` §4 |
| 63 | `[S]` | `prodrigestivill/postgres-backup-local` só faz fan-out de lista CSV em `POSTGRES_DB` | A imagem cobre vários bancos por lista separada por vírgula em **`POSTGRES_DB`**, contra **um** host/usuário/senha. Lista em `POSTGRES_HOST`/`POSTGRES_USER`/`POSTGRES_PASSWORD` não distribui: o valor é usado literalmente e o `pg_dump` tenta autenticar com o usuário `erp,zitadel`. Fix: um service por cluster/credencial. **Armadilha vizinha**: a tag da imagem fixa a versão do `pg_dump`, e um `pg_dump` mais antigo que o servidor recusa o dump — cluster Postgres 17 exige `:17-alpine`, não `16-alpine`. Esse falha alto, mas falha de madrugada e sem plateia, o que dá no mesmo. Ver `cd-verification-and-rollback.md` §5 |
| 64 | `[F]` | Imagem com env de build-time precisa de **sufixo de ambiente** na tag — senão produção sobrescreve staging | Se os dois pipelines derivam a tag do commit (`sha-<short>`, instinto correto), um merge **fast-forward** staging→main produz o MESMO `GITHUB_SHA`: o build de produção sobrescreve no registry a imagem de staging que já vivia naquela tag, com um bundle apontando p/ os hosts de produção. Staging passa a servir produção sem ninguém tocar nele, e o dano só aparece dias depois, sem deploy no histórico p/ culpar. Só morde o **frontend** (o mesmo commit legitimamente gera duas imagens); backend sem env de build é byte-idêntico e o push é idempotente. Fix: sufixo (`…-prod`) aplicado no compose, mantendo um `IMAGE_TAG` único, e asserção de paridade de tags no job de deploy. Ver `cd-pipeline-pitfalls.md` §1c |
| 65 | `[F]` | `20-envsubst-on-templates.sh` varre o diretório INTEIRO — snippet de `add_header` ali vira config de nível `http` | O entrypoint oficial do nginx renderiza **todo** `/etc/nginx/templates/*` para `conf.d/`: é varredura de diretório, não opt-in por arquivo. Um snippet feito p/ ser `include`ado dentro de `server`/`location` acaba em `conf.d/` (parseado no nível `http`) **e** o `include /etc/nginx/snippets/…` do vhost fica órfão. Fix: drop-in próprio numerado **entre 20 e 30**, renderizando de um diretório separado, com lista explícita de vars no `envsubst` (sem argumentos ele come qualquer `${...}` futuro). **Metade fail-fast**: var ausente em `proxy_pass` faz o nginx recusar o parse (auto-defendido), mas var ausente num `add_header` gera CSP com origem **vazia** — válido, `nginx -t` passa, container `healthy`, smoke verde, e o browser bloqueia API e IdP com sintoma que nunca menciona CSP. O entrypoint roda sob `set -e`, então um `exit 1` no drop-in basta. Ver `cd-pipeline-pitfalls.md` §1d |
| 66 | `[S]` | `${{ vars.X }}` resolve org → repo → environment — não conclua "ausente" olhando só o environment | A lista de variáveis do environment vazia não significa que a var não existe: `vars` (como `secrets`) tem hierarquia, e o mais específico vence. Uma var documentada como "do environment `production`" pode viver no nível do REPO e funcionar. Conferir os dois: `gh api repos/<o>/<r>/actions/variables` e `gh api repos/<o>/<r>/environments/<env>/variables`. Consequência inversa: var de repo é compartilhada por TODOS os environments — certo p/ nome de rede docker, errado p/ o que precisa divergir entre staging e produção. Ver `cd-verification-and-rollback.md` §7 |
| 67 | `[S]` | Ausência de sinal não é prova — dispare uma sonda antes de concluir "está limpo" | Captura vazia é produzida por dois mundos: nada de ruim aconteceu, **ou** a captura nunca esteve viva (pipe quebrado, SSH que morreu, padrão de `grep` que não casa, `--since` na janela errada). Os dois se parecem exatamente. Fix: provocar um positivo deliberado (um 404 causado por você) e confirmar que ele aparece no arquivo, antes de tirar qualquer conclusão da ausência de saída. Vale p/ toda asserção negativa do CD: step de log dump que não imprime nada, `find` sem resultado, grep de secret que não acha. Pergunte "isto teria me mostrado um positivo?" antes de acreditar no negativo. Ver `cd-verification-and-rollback.md` §6 |
| 68 | `[S]` | Custo do Actions é por **JOB arredondado para cima ao minuto** — a contagem de jobs é alavanca independente da duração, e o self-hosted é capacidade grátis já paga | Repo **privado** consome cota (público não). Um job de 51s custa **1 minuto cheio**, então o instinto de paralelizar (`lint` e `test` separados) **ADICIONA** um minuto por execução — compra wall-clock com fatura. E o maior desperdício costuma ser estrutural: `build-and-push` e o gate de CI queimando minutos em `ubuntu-latest` enquanto o runner self-hosted, que a org já paga, roda só o `deploy` por 47–103s/dia. Nada nesses jobs exige runner hospedado. ⚠️ **A migração custa mais que trocar o `runs-on`** — e o que quebra não é "service containers exigem Docker" (isso costuma passar de primeira), e sim toolchain ausente, rede do service container e versão do runner (lições 71–73). O build passa a usar CPU/disco do host de deploy (mas o cache de camadas fica quente) e **um runner roda um job por vez** (jobs antes paralelos serializam). Rollout: staging → observar um deploy real → produção. Ver `ci-cost-minutes.md` §1/§3a e o runbook `self-hosted-job-migration.md` |
| 69 | `[S]` | `billable.total_ms` do endpoint `/timing` pode vir **`0`** para um run que claramente rodou — medir por `/jobs` + `runner_group_name` | `gh api /repos/<o>/<r>/actions/runs/<id>/timing` retornando `{"billable":{"UBUNTU":{"total_ms":0,"jobs":2}},"run_duration_ms":322000}` leva à conclusão errada de que não há gasto, e a investigação para aí. Medição confiável: `/actions/runs/<id>/jobs`, separando por **`runner_group_name`** (`GitHub Actions`=hospedado/cobrado, `Default`=self-hosted/grátis) e arredondando **cada** duração para cima. Vizinhos: `/orgs/<org>/settings/billing/actions` responde **410 Gone** e a rota atual exige `admin:org`; e `gh api /repos/.../actions/runners` lista **só runners de repositório** — um runner de organização não aparece, então lista vazia **não** prova que não existe runner (confirme pelo `runner_name` de um deploy que concluiu). Ver `ci-cost-minutes.md` §2 |
| 70 | `[S]` | O mesmo SHA roda o gate de CI **três vezes** (PR → push `develop` → tag) — e a composite action da lição 43 conserta o drift, **não a conta** | No blueprint de 3 workflows o mesmo lint/test executa no `ci.yml`, no job `ci` do `cd-staging.yml` e de novo no do `cd-production.yml`. Deduplicar o **código** (lição 43, composite action) impede divergência entre eles mas **o job continua rodando**: economia zero. Deduplicar *código* e deduplicar *execução* são coisas diferentes; só a segunda aparece na fatura. Preferir **mover os jobs de CI do CD para self-hosted** (lição 68) — mantém os três gates, inclusive o que protege contra push direto em branch protegido (lição 44), e zera o custo dos dois últimos. Só se o self-hosted não puder rodá-los: remover o `ci` do `cd-staging.yml` (o `develop` só recebe PR mergeado) e manter o do `cd-production.yml`. Ver `ci-cost-minutes.md` §3c |
| 71 | `[S]` | Job migrado p/ self-hosted morre no `setup-node` com `Unable to locate executable file: yarn` — o `cache:` **invoca o binário** que o runner hospedado dava de graça | `actions/setup-node` com `cache: 'yarn'` roda o yarn para descobrir o diretório de cache e montar a chave. Em `ubuntu-latest` o yarn vem pré-instalado; num self-hosted, não — e o step morre **antes** de qualquer `yarn install`, logo antes de qualquer chance de instalá-lo. Fix: tirar o `cache:` do `setup-node` e provisionar num step próprio (`corepack enable \|\| true; yarn --version \|\| npm i -g yarn@1.22.22`); perder o cache do Actions custa pouco porque no self-hosted o `~/.cache/yarn` já sobrevive entre execuções. Mesma classe morde `pnpm`, `jq`, `ss`, `ip` — rode um `command -v` descartável antes de migrar. Ver `self-hosted-job-migration.md` §1 |
| 72 | `[S]` | `P1001`/`Connection refused` contra o `services:` container **que está `healthy`** — porque o healthcheck roda DENTRO dele e não prova alcance | Se o runner é **conteinerizado**, `docker create -p 5432:5432` publica no **host** e `127.0.0.1` dentro do job é o loopback do runner: nunca se encontram. Duas hipóteses plausíveis custam rodadas e são falsas — (a) "`localhost` é `::1`": o log do runner imprime `-> 0.0.0.0:5432` **e** `[::]:5432`, então as duas famílias estão publicadas; (b) "é só declarar `container:`": ver lição 73. Fix que serve às três topologias: **descobrir** o host em runtime — gateway da rota default (de `/proc/net/route`, porque `ip` costuma não existir; `strtonum` é gawk e o Debian traz mawk, converta no `printf` do bash) → `127.0.0.1` → `host.docker.internal` → alias do service — e exportar o `DATABASE_URL` do primeiro que responder via `$GITHUB_ENV`. Ver `self-hosted-job-migration.md` §2 |
| 73 | `[S]` | `container:` parece a correção da lição 72 e **não sobe** em runner desatualizado: `exec: "/__e/node24/bin/node": no such file` — o `checkout` nem executa | Com `container:`, o GitHub monta os `externals` do runner em `/__e` e roda as actions com **o Node do runner**; como ele força Node 24, um runner antigo (só `node16`/`node20`) não tem o binário. Assinatura de isolamento: **o mesmo workflow funciona sem `container:` no mesmo runner**. Fixes em ordem — atualizar o runner (correção real, manutenção de host); resolver a rede pela lição 72, que não depende da versão; `ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true` como ponte. O warning `Node.js 20 is deprecated … forced to run on Node.js 24`, que aparece em runs verdes, é o aviso prévio disso. Ver `self-hosted-job-migration.md` §3 |
| 74 | `[S]` | Jobs "falharam" em ~3s, **sem steps**, `runner_name` vazio e sem log (`log not found`) — não é o código, o job **nunca iniciou** | Bloqueio de cota/cobrança da conta. O log não existe porque nada rodou, e a mensagem vive só nas **annotations do check-run**: `gh api /repos/<o>/<r>/check-runs/<job_id>/annotations` → *"The job was not started because recent account payments have failed or your spending limit needs to be increased"* (ambígua de propósito: cobre pagamento recusado E limite, que têm curas diferentes). Consequência que muda a prioridade: **self-hosted não passa pela cota**, então mover o gate de CI para lá deixa de ser economia e vira o que faz o gate **existir** — um `ci.yml` em `ubuntu-latest` sob bloqueio não reprova nada, só não roda, e o PR fica sem rede de segurança sem ninguém notar. Não esqueça o `cd-production.yml`: se ficar no hospedado, o bloqueio aparece no dia da tag de release. Ver `ci-cost-minutes.md` §5 |
| 75 | `[S]` | Migrar o `cd-production.yml` (tag) p/ self-hosted quando o runner de produção é de organização/desconhecido: rodar `ci`/`build-and-push` no runner **já provado** e só o `deploy` no runner do ambiente | O repo não lista runner de org (`403` sem `admin:org`), então não se sabe se ele tem Docker/buildx/yarn; estreá-lo na tag é o pior momento. A imagem vai ao GHCR e produção só puxa — o build pode rodar em qualquer runner confiável. Copiar o job `ci` do `cd-staging` e provar com `diff` (sem comentários) que é byte-idêntico. Ver `self-hosted-job-migration.md` §5b |
| 76 | `[S]` | Workflow disparado por **tag** só vale no **commit taggeado** — migrar e mesclar antes de cortar a tag; não há dry-run | O GitHub executa o arquivo do commit do evento: em `push` de branch a migração vale no próprio merge; em `tag` precisa já estar na branch de onde a tag sai. Evidência aceitável sem taggear: o workflow irmão verde com jobs idênticos + o CI do PR da mudança. Ver `self-hosted-job-migration.md` §5b |
| 77 | `[S]` | A migração para self-hosted **desvenda a dívida do repo** — o job fica verde de primeira e o REPOSITÓRIO é que está vermelho | Todo o resto do runbook parte de "o job quebra porque falta algo no runner". Existe outra classe: um CI bloqueado por cota (lição 74) não reprova nada, mas os merges continuaram — quanto mais durou o bloqueio, mais vermelho acumulou, e a migração revela tudo de uma vez, no PR que só queria trocar `runs-on` (medido: runner pegou o job de primeira, e apareceram 5 testes + 1 ESLint preexistentes). Triagem: **dois PRs** — o de pipeline fica vermelho e isso é honesto (ele revela a dívida, não a criou), e o de defeitos vai separado, com a ordem de merge dita no corpo. Prove que é preexistente ANTES de afirmar (`git diff origin/<base>...HEAD --stat` + reproduzir no `<base>`), senão a conversa vira "sua migração quebrou o build". E **teste vermelho ≠ código quebrado**: dos 5, 3 eram setup de teste que contradizia a própria asserção (um deles impossível de satisfazer) — na janela cega os testes também nunca executaram. Ver `self-hosted-job-migration.md` §6 |
| 78 | `[F]` | Sem `.env` no CI, módulo que faz `throw` no **nível do módulo** derruba a suíte inteira no import — não é "1 teste falhou" | O `.env` é gitignored (corretamente), então o checkout do CI não tem nenhum; um guard de env em top-level mata todo arquivo de teste que importe aquele módulo transitivamente. Assinatura: **o mesmo erro repetido em arquivos sem relação, nomeando um MÓDULO em vez de um teste** (aqui, 9 módulos, centenas de linhas idênticas). Fix: `env:` no nível do job. A classe **não é específica de self-hosted** — falharia igual em `ubuntu-latest`; ela aparece na migração porque é quando os testes voltam a rodar. Descubra a lista completa de uma vez (`grep -rhoP "Missing required environment variable \\K[A-Z_]+" src/ \| sort -u`, ou `mv .env .env.bak && <test>; mv .env.bak .env`) em vez de gastar uma rodada de CI por variável. Ver `self-hosted-job-migration.md` §7 |
| 79 | `[F]` | Snapshot que grava valor de env força o CI a reproduzir o valor **exato** — e a mensagem (`Snapshot mismatched`) não menciona ambiente | Depois de resolver a lição 78 a suíte para de explodir e sobram falhas de snapshot: o componente renderiza `href` de `import.meta.env.VITE_*` e o `.snap` versionado guardou o valor renderizado. Essas variáveis **não** são placeholder livre — têm um único valor aceitável, e ele está no `.snap`. Descubra em um comando (`grep -oP 'href="\\K[^"]*' **/__snapshots__/*.snap \| sort -u`) em vez de por eliminação. Injetar o valor do `.snap` desbloqueia; desacoplar o componente do env no teste remove a classe. Em qualquer caso, comente no bloco `env` que o valor está amarrado ao `.snap`, senão a próxima pessoa "limpa o placeholder" e quebra o CI sem tocar no teste. Ver `self-hosted-job-migration.md` §8 |
| 80 | `[S]` | Job de **gate** muda de natureza ao migrar: o que vigia o runner não pode rodar nele — e o job continua passando, então nada avisa | O YAML muda igual a qualquer outro job; o que muda é o que o verde significa. Um `preflight` que barra o deploy sem runner com o label X (lição 51) vira **tautológico** em `[self-hosted, X]`: com o runner offline ele próprio fica `queued` — exatamente o silêncio que existia para quebrar. O critério é **qual runner vigia qual**: preflight de staging rodando no runner staging ❌ perde o fail-fast; preflight de **produção** rodando no runner **staging** ✅ o mantém (máquinas diferentes); o **watchdog agendado fica em `ubuntu-latest`** mesmo sob bloqueio de cota — alarme morto é melhor que alarme verde que não enxerga, e é o que sobra da camada de detecção. Migrar o preflight de staging costuma ainda ser certo (ele é `needs:` do deploy e o hospedado mataria o CD inteiro) — só não deixe o arquivo afirmando o que ele não garante mais. Ver `self-hosted-job-migration.md` §9 |
| 81 | `[S]` | `paths-ignore` pula também o push que **não altera arquivo nenhum** — e é assim que uma branch de ambiente nasce sem deploy | O filtro pula quando *todos* os arquivos do push casam; com **zero** arquivos a condição é **vacuamente verdadeira**. Criar `staging` apontando para um commit que já existe é exatamente esse push: nada falha, nada fica vermelho, o `gh run list` segue mostrando runs antigos e o silêncio se parece com "deploy ok" — merge correto, runner online, zero runs. Dois agravantes fecham a saída: `git commit --allow-empty` cai na mesma condição (zero arquivos alterados), e a **doc oficial do GitHub não cobre o caso** — confirme empiricamente com `gh run list --workflow=<wf> -L 3`, não procurando na documentação. Fix: `workflow_dispatch` como válvula permanente, com um detalhe de ordem que decide tudo — o GitHub lê o workflow **do ref**, então ele precisa já estar no commit para onde a branch aponta; somar depois de cortar a branch não cria botão nenhum. Sem ele, a única saída é empurrar um commit que altere arquivo fora do `paths-ignore`, sujando a branch de ambiente com conteúdo que não veio da promoção. Ver `ci-cost-minutes.md` §3b |
| 82 | `[S]` | O `actionlint` é o único linter que lê as duas metades de um workflow (YAML + shell dos `run:`) — e **cega em silêncio** quando falta o `shellcheck` | `yaml.safe_load` e `bash -n` respondem "bem-formado", nunca "correto": não olham contexto de expressão, ref de `uses:` nem label de `runs-on:`. O actionlint olha, mas **delega o shell ao `shellcheck`** e, sem o binário no PATH, desliga a regra sem aviso: medido no mesmo arquivo com `if` sem `then`, **3 erros e exit 1** com shellcheck, **saída vazia e exit 0** sem ele. Não há como forçar — `-shellcheck=<inexistente>` também sai 0; só `-verbose` imprime `Rule "shellcheck" was disabled`. Isso torna "passou no actionlint" inverificável sem dizer se o shellcheck estava lá, e no CI a imagem decide por você (`ubuntu-latest` traz, `slim`/container próprio não) — o gate fica verde tendo lido metade. Dois detalhes que custam tempo: fora de repo git ele sai **3** (não 1) com mensagem que culpa o layout (`check workflows directory is put correctly`), e ele reprova label self-hosted desconhecido — a cura é declarar o inventário em `.github/actionlint.yaml`, não afrouxar o `runs-on:`. Ver `troubleshooting-shared.md` §12 |
| 83 | `[S]` | A sintaxe de expressao do Actions (`${{ }}`) dentro de um **comentario de shell** num `run:` invalida o workflow inteiro — e o sintoma se disfarca de bloqueio de cota | O parser procura o delimitador em QUALQUER ponto do bloco; `#` nao protege nada, e uma expressao **vazia** basta. Quem digita isso e justamente quem esta *explicando* interpolacao (ex.: por que o heredoc precisa ser citado) — o comentario sobre interpolacao quebra o workflow por interpolacao. O run fica vermelho com **zero jobs** e `log not found`, quase a assinatura da licao 74: o discriminador e o **`.name` do run**, que vira o CAMINHO do arquivo quando o GitHub nao consegue ler o `name:` de dentro dele. E e pior que CI vermelho — o workflow invalido **nao dispara nem nos proprios gatilhos**: o push na branch de deploy nao cria run algum, o merge parece concluido e o ambiente segue servindo a imagem antiga. A cura e o `actionlint` **no gate**, nao so configurado: um `.github/actionlint.yaml` sem nada o executando documenta intencao e nao mede nada (a licao 82 leva a criar o arquivo, e so) — teste/format nao leem YAML, entao a unica forma restante de descobrir era empurrar para a branch de deploy e nao ver deploy. Valide o sensor nos DOIS sentidos: com o defeito reintroduzido ele sai 1 apontando linha:coluna; corrigido, 0. Ver `cd-pipeline-pitfalls.md` §9 e `ci-cost-minutes.md` §5 |
| 84 | `[S]` | `cmd \| tail` descarta o exit code do gate, e o reflexo `${PIPESTATUS[0]}` **nao existe no zsh** — o pre-flight imprime vazio e nao mede nada | O status de um pipeline e o do **ultimo** comando, entao `npm test \| tail -25` sai 0 com a suite vermelha: canalizar o gate para encurtar o log converte toda falha em passe. O conserto obvio e bash-only — em zsh o array e `$pipestatus`, **1-indexed**, e `${PIPESTATUS[0]}` nao da erro: expande para **string vazia**. O gate entao atribui `EXIT_TEST=` e toda comparacao posterior opera sobre nada. O valor errado e *vazio*, nao um numero errado, entao um teste de "diferente de zero" nunca dispara. Medido: os **tres** gates de uma rodada (test/lint/typecheck) reportaram vazio de uma vez, e a leitura natural do log foi "passou". Agrava que `run:` no Actions e bash por padrao, mas o mesmo gate rodado localmente como pre-flight — por gente ou por agente cujo shell e zsh — emudece: o filtro que existia para pegar a falha antes do CI vira decorativo, e os dois transcripts ficam identicos. Fix portavel: redirecionar para arquivo e ler `$?` direto, ou `set -o pipefail`. Ver `cd-verification-and-rollback.md` §8 |
| 85 | `[F]` | Job de teste **verde** com 1.000+ linhas de ruido: a maior parte e lacuna do ambiente, mas `onUnhandledRequest: "warn"` e sensor cego no meio dela | Quando tudo grita, nada grita — e "e so ruido de jsdom" e leitura, nao medicao. Agrupe por assinatura antes de decidir (`grep -oE 'Error: .{0,90}' \| sed -E 's/[0-9]{3,}/<n>/g' \| sort \| uniq -c \| sort -rn`): numa medicao real de 2.451 testes, 591 eram `window.scrollTo` nao implementado (silenciar e seguro), 114 avisos de migracao, ~106 updates fora do `act()` — e **354 requisicoes sem handler de mock**, que sao de outra classe. Com `"warn"`, a requisicao sem handler nao falha o teste: o codigo recebe erro de rede e cai no proprio `catch`, que quase sempre e fallback silencioso (`.catch(() => null)`). O teste passa **medindo o caminho de erro** enquanto promete medir a funcionalidade — mesma familia da licao do typecheck vacuo, so que aqui o gate esta certo e o *teste* e que ficou cego. Virar para `"error"` nao e troca de uma linha: as 354 caem juntas. Acrescente primeiro os handlers das rotas que o agrupamento ja enumera, depois vire a chave, e declare a excecao no teste onde a falha de rede E o cenario. Antes de virar, confira por rota se algum teste afirma o caminho de sucesso: se afirma e passa, ele mocka por outro meio e esta saudavel; se nenhum afirma, a funcionalidade nunca foi medida — o `"error"` nao cria trabalho, revela o que faltava. Ver `troubleshooting-frontend.md` §10 |
| 86 | `[S]` | Runner que **recusa trabalho sem anunciar**: `online`, `healthy`, `busy=false` — e mudo. O §8 procura uma frase que pode não existir | O §8 ensina a reconhecer o binário deprecado pelo log `Runner version vX is deprecated and cannot receive messages` e pelo crashloop com `RestartCount` nos milhares. Existe uma variante em que **nenhum dos dois aparece**: com a migração da entrega de jobs para o serviço **Broker**, o runner velho fica conectado, escutando e nunca designado, com o contêiner `Up 19 hours (healthy)` e `RestartCount` 28. Todos os sinais que se checaria — `docker ps`, `gh api …/runners`, `docker logs` — respondem "saudável", e o único vestígio é `BrokerMigration message received` 1×/min no `_diag`. **O sensor decisivo está no filesystem, não no log**: `ls -d /actions-runner/bin.*` ao lado de um `bin/` antigo prova que o auto-update baixou e o swap nunca aterrissou — e ele é **indicador antecedente**, não post-mortem: no caso medido esteve lá **17 dias** com o runner ainda pegando jobs, porque o update foi *oferecido* em 01/09 e só virou *exigido* em 18/09, quando o runner emudeceu em ~35 segundos. Causa estrutural (licao 87) e triagem que refuta a hipótese do Environment **de dentro do próprio run** em `self-hosted-runner-docker.md` §8b |
| 87 | `[S]` | Auto-update LIGADO não garante runner atualizado — em contêiner efêmero que limpa state, ele é estruturalmente impossível | A licao 48 diz que `DISABLE_AUTO_UPDATE` desliga com qualquer valor; daí se conclui que **removê-la** resolve. Não resolve quando o entrypoint apaga `.runner`/`.credentials` a cada start: o update tem duas metades — baixar `bin.<nova>/` e, **ao sair**, trocar `bin/` — e a segunda nunca acontece, porque o processo volta do `bin/` que veio na imagem, imutável sob pin por digest. Medido: `bin/` de ago/2024, `bin.2.337.0/` baixado três semanas antes, `DISABLE_AUTO_UPDATE` ausente, runner mudo. Isso **fecha a saída (a) da licao 49** (`:latest` + auto-update) para esse desenho: sobra pin + cadência de bump, e quem conserta é o `FROM`. Ver §8a/§8b |
| 88 | `[S]` | As duas camadas da §11 **não pegam** o runner mudo — falta uma terceira, que mede a causa em vez de um efeito | Preflight de **presença** responde "tudo bem" (o runner está genuinamente `online`), e watchdog de **deploy preso** não vê nada até alguém tentar deployar — no caso medido o runner emudeceu às 15:15 e o deploy só veio às 16:57, então ele teria acusado ~17:30, uma hora antes do humano. Útil, e tarde. A que tem antecedência REAL — 17 dias, porque o runner passou esse tempo com o update baixado e não aplicado, trabalhando normalmente — compara `.runners[].version` com a release corrente de `actions/runner`, **lida na hora** (número cravado envelhece calado e vira sensor que aprova o que deveria reprovar). Limiar em **minors**, não igualdade: exigir a última transforma o gate em ruído mensal. ⚠️ Rotação **acopla** — se o PAT for o mesmo `ACCESS_TOKEN` que registra os runners, rotacioná-lo degrada preflight **e** currency ao mesmo tempo, sobrando só a camada que já sabemos que não pega. §11 C |
| 89 | `[S]` | O clone do operador (licao/§2) é inerte para a app e é o **único** caminho de deploy do RUNNER | O §2 manda apagar o clone porque o CD faz seu próprio checkout — certo para a aplicação, e a prova custa um comando (o contêiner vivo carregava `BACKUP_ON_START=TRUE` que o compose do host nem tinha). Mas o `up -d` do CD **lista os serviços** e nunca inclui `runner`, porque um runner não pode se deployar: ali o drift não aparece em deploy nenhum e só morde no dia em que alguém o recria. Consequências: não apague o clone onde o runner é definido — estreite o uso para `up -d --no-deps runner`; lembre que o `.env` dele **não tem os segredos da app** (um `up -d` nu sobe container com credencial vazia, a armadilha do `JRC_API_TOKEN` chegando à mão); e **sincronize o mínimo** — copiar um `entrypoint.sh` mais novo que o compose do host derrubou um runner em crashloop por env var ausente. §2a |
| 90 | `[S]` | O runner conteinerizado NÃO compartilha o filesystem do host — e metade do comando funciona, que é o que engana | Num runner socket-mount, `-v /host/path:/dentro` é resolvido pelo **daemon** (existe no host, funciona) enquanto `--env-file`, `-f` e `--project-directory` são abertos pelo **CLI**, que roda dentro do container (não existe, falha). O mesmo `docker compose` mistura os dois mundos numa linha, então "o container vê o host" parece confirmado por metade da evidência. O sintoma é um passo reprovando por caminho ausente com o arquivo intacto no host — leitura natural manda para "sumiu" ou "é permissão", e nenhuma das duas é verdade. Terceiro eixo da mesma família: `self-hosted-job-migration.md §2` é a **rede** (`127.0.0.1` é o loopback do runner), `cd-pipeline-pitfalls.md §6` é a **permissão** (uid 1001 × 1000), este é a **existência do caminho** — e é o menos visível dos três, porque os outros dois dão erro sobre a coisa certa. Fix: montar o **diretório** no mesmo caminho, `ro`. Diretório e não arquivo porque rotação de segredo reescreve por substituição e bind mount de arquivo prende o **inode** — o container seguiria lendo a versão velha, calado. E `ro` basta: quem roda ali já tem o socket do Docker, que é root-equivalente e domina qualquer leitura. ⚠️ A montagem só chega ao host por `up -d --no-deps runner` (§2a) — o CD lista os serviços e nunca inclui o runner. Ver `self-hosted-runner-docker.md` §12 |
| 91 | `[S]` | `compose run`/`up` não RECONSTRÓI imagem cuja tag já existe — a lição 29 pelo lado do *build*, e pior, porque o artefato suspeito é o seu próprio repo | Service com `build:` **e** tag fixa (`image: x/y:staging`) só é construído quando a tag não existe localmente; depois disso o contexto de build é ignorado. Morde muito mais quando o **input** do passo vive DENTRO da imagem (`COPY config.yaml` em vez de volume): a imagem velha carrega a **declaração** velha e o passo a aplica fielmente, com log tranquilizador (`reuse` em tudo). Discriminador: pipeline que constrói e **empurra por `sha-<commit>`** ao registry é imune (tag imutável); o que constrói local com tag de ambiente não é — e esse é quase sempre o de **staging**, o ambiente que existe para descobrir problemas antes de produção. Fix: `--build` no `compose run` (Compose ≥ 2.13) ou um `compose build <svc>` explícito. Diagnóstico: `docker image inspect -f '{{.Created}}'` e `docker run --rm --entrypoint sh <img> -c 'cat /config/<arquivo>'` — pergunte a idade da imagem e o **input** que ela carrega, não o log do passo. Medido: imagem de 13/jun rodando num deploy de 18/set, verde, com 2 dos 4 itens declarados. Ver `cd-pipeline-pitfalls.md` §10 |
| 92 | `[S]` | Passo cujo produto é ESTADO precisa de gate sobre o estado — o smoke prova que o serviço responde, e isso é verdade antes e depois dele | Deploy normalmente entrega **serviço**, e o smoke certo para serviço é "responde pelo caminho do usuário". Mas bootstrap de IdP, seed de catálogo e migration de dados entregam **fato**, e ali o smoke passa igual com o passo tendo feito nada. Agrava que esses passos são idempotentes por desenho, e idempotência tem vocabulário próprio (`reuse`, `already exists`, `no changes`) que é **indistinguível** entre "já estava certo" e "li uma declaração velha e concordei com ela" — o log mais tranquilizador do pipeline é o que menos distingue. Mesma família do §4 (backup `healthy` sem dump) e do §6 (provar o sensor): o sinal observa algo **adjacente** ao trabalho. Fix: derivar a asserção da **declaração** (cada item do YAML existe depois?), o que pega tanto o passo que não rodou quanto o que rodou contra input velho (lição 91); a saída do próprio passo costuma bastar, sem credencial nova. Pergunta que generaliza: *este passo produz um serviço ou um fato?* Medido: quatro passos verdes, zero papéis criados, e quem desmentiu foi um `SELECT` rodado à mão por desconfiança. Ver `cd-verification-and-rollback.md` §9 |
| 93 | `[S]` | Sabotar o código para provar um sensor e ler exit 0 como "o sensor é cego" — quando a **sabotagem** é que nunca foi aplicada | O §6 do `cd-verification-and-rollback.md` cobre a sonda que nunca rodou; esta é a mesma falha uma camada adentro — a *edição* nunca aconteceu. `str.replace` do Python devolve a string **intacta** quando o padrão não casa, e `sed -i` sai **0** sem tocar o arquivo: os dois são silenciosos, e um padrão multilinha montado de memória erra por uma linha de comentário no meio. Os transcripts são idênticos — "rodei o sensor sobre o código sabotado e passou" — e só um é verdade. Pior que cheque perdido: publica-se que a ferramenta é cega quando ela não é, e o próximo confia e dispensa a guarda. Medido: uma sabotagem para mostrar o `actionlint` ignorando `${{ }}` em comentário de `run:` não entrou; o lint rodou no arquivo ORIGINAL e saiu 0. Refeito com `assert padrão in texto` + `grep` do defeito: exit 1, `expression: potentially untrusted`, linha:coluna — a ferramenta estava certa (é a mesma detecção da licao 83). Ver `cd-verification-and-rollback.md` §6a |
| 94 | `[B]` | `migrate deploy` verde + contêiner `healthy` + smoke verde, e o `REVOKE` das tabelas append-only **não está valendo** | Esquema com append-only precisa de DUAS identidades no banco, e o deploy carrega as duas: a migration exige o **dono** (cria tabela, papel e grant); a aplicação exige o papel **restrito**, porque quem impõe o REVOKE é o banco. Trocar é pior que omitir — rodar migration com o restrito falha alto, mas rodar a **aplicação** com o dono funciona: o REVOKE vira decorativo em runtime e sobrevive só dentro do teste de integração, onde a fixture conecta certo. Prove pelo banco, depois que o smoke abriu o pool: `SELECT DISTINCT usename FROM pg_stat_activity WHERE datname=… AND backend_type='client backend'` — e asserte as DUAS metades (restrito presente **e** dono ausente); só a primeira passa com um segundo pool rodando como dono. Ver `cd-verification-and-rollback.md` §10 |
| 95 | `[B]` | Senha de banco **comprovadamente correta** e a aplicação não autentica — o `psql` entra com a mesma string | A senha está certa *como senha* e errada *como componente de URL*. Driver que recebe connection string faz parse RFC 3986 do userinfo: `#` trunca como fragmento, `/` e `?` encerram a authority, `%` inicia escape, `@` e `:` dividem o campo. O banco recebeu uma string e o driver manda outra — e os dois lados parecem certos isolados, porque só a URL parseada difere e nada a imprime. A investigação vai para o papel, os grants, o `pg_hba.conf`; nenhum tem defeito. Cura: **restringir o alfabeto** (`^[A-Za-z0-9]+$`, `openssl rand -hex 32` = 128 bits) em vez de percent-encodar — encodar obriga a manter DUAS formas do mesmo segredo, crua para o `ALTER ROLE` e encodada para a URL, e errar essa distinção reproduz exatamente a falha silenciosa. Ver `cd-pipeline-pitfalls.md` §11 |
| 96 | `[F]` | Login funciona, smoke verde, e2e verde — e 15 min depois os usuários são deslogados "do nada", sem deploy no histórico para culpar | Uma SPA registra TRÊS URIs no IdP e todas são comparadas byte a byte, barra final inclusive. O callback quebra alto e qualquer teste de login o pega; o **silent renew** dispara no fim da vida do access token (5–15 min) e **nenhum teste de login chega lá** — ele termina em segundos. Nomear o e2e como mitigação de "redirect errado" é sensor cego: cobre a URI que falharia sozinha e perde a que falha tarde e é atribuída a outra coisa. Agrava que os caminhos **não são simétricos** — é comum só o callback morar sob um prefixo (`/auth/callback`, mas `/silent-renew`), e escrever `/auth/silent-renew` por analogia erra nos dois lados de uma vez: rota inexistente na SPA e registro inexistente no IdP. Cura: teste de contrato comparando os `--build-arg` do CD com as rotas reais do roteador, **com piso anti-vacuidade** (roteador renomeado devolve conjunto vazio e tudo passa). Ver `cd-pipeline-pitfalls.md` §12 |
| 97 | `[S]` | `<comando>: command not found` num step do CD **depois** de gates, builds e push de imagens — um comando trivial que existe no host e existiria no `ubuntu-latest` | A licao 90/§12 é sobre o FILESYSTEM do runner; esta é sobre o FERRAMENTAL. Num runner conteinerizado a imagem é sua e é mínima de propósito (o `myoung34/github-runner` traz o agente, não uma distro de CI), e há uma assimetria que faz o erro escapar de toda revisão: o workflow vive no repo e é revisado em PR; a imagem do runner é construída **à mão no host, fora do CD** — o `up -d` do deploy nunca inclui o `runner` (§2a), então ela só muda quando alguém lembra. Ensinar um comando novo ao workflow é uma linha inocente num PR verde. ⚠️ E **não troque a ferramenta pela que já está lá** só para destravar: substituir `dig` por `getent` num runner com `extra_hosts` faz o gate ler `/etc/hosts` e medir a própria configuração — verde medindo outra coisa, pior que o `command not found`. Sonde primeiro se falta binário ou falta rota (e use `docker exec -i`: sem `-i` o heredoc chega vazio e a sonda "passa" sem medir). Fix: contrato entre workflow+scripts e o Dockerfile do runner, medido nos DOIS sentidos — senão a lista de ferramentas vira folclore. Ver `self-hosted-runner-docker.md` §13 |
| 98 | `[S]` | Step de verificação que **reprova em toda corrida** num sistema comprovadamente correto — e acusa um defeito, em geral de segurança, que não existe | O gate abre uma conexão para fazer a pergunta, e a conexão aparece na resposta. `pg_stat_activity` é o caso afiado porque a consulta natural roda por `psql` **como o dono do banco** — exatamente a identidade que o gate assere estar ausente. Medido em ambiente vivo com a aplicação correta: com a conexão própria o resultado traz `<dono>, <papel_app>`; sem ela, só `<papel_app>`; o detalhamento mostra `<dono>|psql|1`, que é a pergunta aparecendo na própria resposta. **Gate que nunca deixa passar é tão inútil quanto o que nunca reprova**, e decai igual — alguém põe `continue-on-error` ou para de ler o step. Cura: `AND pid <> pg_backend_pid()`. A classe é maior que Postgres: `docker ps` de dentro de um container do mesmo projeto, contagem de conexões feita abrindo uma conexão, cheque de lock segurado por quem checa. Pergunte **"o meu ato de medir aparece neste resultado?"** antes de asserir AUSÊNCIA — asserir presença costuma sobreviver à poluição, e é por isso que só metade desses gates é pega. Ver `cd-verification-and-rollback.md` §11 |
| 99 | `[S]` | Você apaga a linha que o sensor existe para proteger, roda o sensor, e ele continua **verde** | A asserção é `grep`/`toContain` sobre o arquivo, e o arquivo também contém o comentário que explica aquela linha. **Quanto melhor a documentação, mais cego o sensor**: um bloco `<CRITICAL>` que nomeia a flag ou a função está fazendo o seu trabalho — e satisfaz a busca por substring sozinho. Dois casos medidos com horas de diferença no mesmo código: a varredura de resíduo no compose ficava verde por causa do cabeçalho de procedência que lista exatamente aqueles nomes para dizer por que ficaram de fora; a asserção do `pid <> pg_backend_pid()` ficava verde por causa do `<CRITICAL>` que explica a exclusão. O primeiro é pior que falso negativo: uma asserção sobre texto cru **proíbe a própria explicação** — o comentário que ensinaria a próxima pessoa vira falha de teste e é apagado, e o sensor remove calado a documentação de que depende. Cura: leia o que a MÁQUINA lê — parseie o arquivo estruturado e asserte sobre o objeto resolvido, ou retire as linhas de comentário antes de buscar. E sabote: nos dois casos a primeira sabotagem passou. Ver `cd-verification-and-rollback.md` §6b |
| 100 | `[S]` | vhost novo nunca recebe certificado, com DNS, porta 80 e companion **todos comprovadamente certos** — e esperar não resolve | O par 3a/3b da §3 (não existe × existe e a janela de reload enganou) não é exaustivo, e o caso que falta é o que mais custa, porque os dois diagnósticos estão errados **e** os fixes deles são no-ops. O companion TENTOU e a chamada à API do ACME falhou na rede (`error code: 35`, `Create new order error. Le_OrderFinalize not found.`); o pedido é abandonado e ele só tenta de novo no ciclo dele, tipicamente horário. **O discriminador é de graça: olhe um hostname IRMÃO** — se outro vhost recebeu certificado do MESMO companion minutos antes, DNS, porta 80, firewall e companion estão todos provados, e a falha é por-pedido, não ambiental. Cura: confirmar alcance de saída e **forçar** a reconciliação (`/app/signal_le_service`), não esperar — medido, emitiu ~30 s após o sinal, para um host travado havia mais de dez minutos. ⚠️ Muda o desenho do smoke: retry em `curl: (60)` assume 3b, e sob 3c a janela é gasta à toa **por maior que seja** — não conserte alargando o número, faça o step dizer QUAL dos três é. Ver `troubleshooting-shared.md` §3c |
| 101 | `[S]` | Runner self-hosted roda como **root** — teste de permissão (`chmod`) para de medir e reprova, com o código de produção certo | `chmod 0o500` não impede root de escrever (`CAP_DAC_OVERRIDE`): o teste que simula falha de disco recebe `null` em vez do erro. No `ubuntu-latest` o usuário era `runner`, então a premissa falsa nunca apareceu — é a dívida do §6 na forma mais concreta. Cura em duas metades: o sensor que roda sempre usa falha que root não contorna (**raiz do storage apontada para um arquivo** → `ENOTDIR` em todo `mkdir`, independente do layout interno por hash), e o de `EACCES` fica com `it.skipIf(process.getuid?.() === 0)` e o motivo escrito. Prove o regime do CI sem esperar o CI: `docker run … node:22 npx vitest run` roda como uid 0 (Debian, não Alpine — binários nativos do `node_modules` são glibc), e saboteie o embrulho de erro confirmando por `grep` que a sabotagem entrou. Ver `self-hosted-job-migration.md` §6a |
- | 102 | `[S]` | `Test timed out in 5000ms` no runner self-hosted num teste de 0,5 s na máquina de desenvolvimento — e uma dúzia de vizinhos logo atrás | O default do test runner foi calibrado para a máquina de quem escreveu o teste. Host self-hosted é tipicamente compartilhado, com poucas CPUs e `_work` em volume nomeado, e a suíte paralela disputa I/O: uma varredura de 1500 arquivos foi de 0,5 s para **11,5 s** (25×), e casos de formulário chegaram a 4,5 s (90% do limite). Não conserte só o que reprovou: liste os casos **individuais** do log (linha de arquivo mostra total e engana — o limite é por caso), ponha `testTimeout` com ~3× o pior medido **com o porquê na config**, e dê timeout próprio à varredura de I/O. Prove a config com uma sonda de 7 s que passa com ela e dá timeout com `--testTimeout=5000`. Ver `self-hosted-job-migration.md` §6b |
+ | 102 | `[S]` | `Test timed out in 5000ms` no runner self-hosted num teste de 0,5 s na máquina de desenvolvimento — e uma dúzia de vizinhos logo atrás | O default do test runner foi calibrado para a máquina de quem escreveu o teste. Host self-hosted é tipicamente compartilhado, com poucas CPUs e `_work` em volume nomeado, e a suíte paralela disputa I/O: uma varredura de 1500 arquivos foi de 0,5 s para **11,5 s** (25×), e casos de formulário chegaram a 4,5 s (90% do limite). Não conserte só o que reprovou: liste os casos **individuais** do log (linha de arquivo mostra total e engana — o limite é por caso), ponha `testTimeout` com ~3× o pior medido **com o porquê na config**, e dê timeout próprio à varredura de I/O. Prove a config com uma sonda de 7 s que passa com ela e dá timeout com `--testTimeout=5000`. ⚠️ **Há um segundo timeout que o `testTimeout` não toca**: o `asyncUtilTimeout` do Testing Library (1 s, do `waitFor`/`findBy*`) — medido, uma animação de saída do `AnimatePresence` deixou duas linhas no DOM por mais de 1 s e o `waitFor` desistiu; cura `configure({ asyncUtilTimeout: 5_000 })` no setup. Reproduza encolhendo-o para 30 ms (determinístico), não com `taskset -c 0` (não discrimina: a falha depende da contenção da suíte paralela). Ver `self-hosted-job-migration.md` §6b |
| 103 | `[S]` | Build migrado para self-hosted com `setup-buildx` + `cache type=gha` copiados do hospedado — cache descartado a cada job e dependente da infra que o bloqueio derruba | `setup-buildx-action` cria um builder `docker-container` descartável por job, jogando fora o cache de camadas que o host persistente daria de graça; e `type=gha` não é suportado pelo driver `docker` e depende da API de cache do Actions — manter o cache nela anula metade do motivo de migrar sob bloqueio de cota. Use o builder `default` do daemon (`docker buildx ls` de dentro do runner) e limite o disco com `docker builder prune -f --filter until=168h` no deploy — por idade, não `--all`, porque o cache é do daemon e compartilhado com os outros projetos do host. Ver `self-hosted-job-migration.md` §11 |
| 104 | `[S]` | Preflight de staging: a tabela da §9 oferecia mover (tautológico) ou manter no `needs:` (o bloqueio mata o CD) — falta a terceira saída | **Hospedado e fora do `needs:`**: continua vigiando de fora e deixando o run vermelho quando não há runner, e não barrar custa pouco — sem runner, o `deploy` também não roda. Sob bloqueio de cota ele morre em ~3 s e o run fica vermelho com o **deploy verde**; é o vigia dizendo, com verdade, que não conseguiu vigiar, e o comentário do job precisa dizer isso. Decisão de humano (o que vigia runner), não de agente. Ver `self-hosted-job-migration.md` §9 |
- | 105 | `[S]` | Deploy manual sob bloqueio de cota: dois falsos verdes medidos na mesma receita — exit 0 sem nada ter mudado | (a) `docker compose run` **sem `-T`** consome o stdin: um script passado por `bash -s < deploy.sh` entrega o resto de si ao `run … migrate deploy`, o bash chega ao EOF e sai **0** sem ter feito `up`. (b) O ansible ad-hoc com `-a "cmd=… stdin=…"` em forma livre **ignora o `stdin`** em silêncio: o `docker login --password-stdin` não loga e o pull responde `denied`; em JSON (`-a '{"cmd":…,"stdin":"{{ lookup(\"pipe\",…) }}"}'`) chega, e `wc -c` prova sem imprimir. A receita (build local com `write:packages`, `.env` reconstruído dos containers vivos, deploy dentro do runner) e a regra que pega os dois: prova por `Created`/`Config.Image` do container, nunca pelo RC do script. Ver `ci-cost-minutes.md` §5a |
+ | 105 | `[S]` | Deploy manual sob bloqueio de cota: dois falsos verdes medidos na mesma receita — exit 0 sem nada ter mudado | (a) `docker compose run` **sem `-T`** consome o stdin: um script passado por `bash -s < deploy.sh` entrega o resto de si ao `run … migrate deploy`, o bash chega ao EOF e sai **0** sem ter feito `up`. (b) O ansible ad-hoc com `-a "cmd=… stdin=…"` em forma livre **ignora o `stdin`** em silêncio: o `docker login --password-stdin` não loga e o pull responde `denied`; em JSON (`-a '{"cmd":…,"stdin":"{{ lookup(\"pipe\",…) }}"}'`) chega, e `wc -c` prova sem imprimir; e a `lookup('file')` de um JSON chega como **dict Python** (aspas simples) — entrada estruturada vai em base64. A receita (build local com `write:packages`, `.env` reconstruído dos containers vivos, deploy dentro do runner) e a regra que pega os dois: prova por `Created`/`Config.Image` do container, nunca pelo RC do script. Ver `ci-cost-minutes.md` §5a |
---
## Useful Commands
```bash
# View workflow status
gh run list --limit 5
# View logs of a specific run
gh run view <run-id> --log-failed
# Re-run a failed workflow
gh run rerun <run-id>
# List secrets of an environment
gh secret list --env staging
# Check images on GHCR
gh api orgs/<ORG>/packages/container/<PACKAGE_NAME>/versions
```
### Validar os workflows antes de commitar
`actionlint` lê YAML **e** o shell dos `run:` — mas só a segunda metade se o
`shellcheck` estiver no PATH, e ele não avisa quando não está (lição 82).
```bash
# Instalar (binario Go unico, sem root) + a metade que importa
VER=$(gh api repos/rhysd/actionlint/releases/latest --jq .tag_name | tr -d v)
curl -sL "https://github.com/rhysd/actionlint/releases/download/v${VER}/actionlint_${VER}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin actionlint
command -v shellcheck || sudo apt-get install -y shellcheck
# Provar que enxerga o shell ANTES de confiar no verde
actionlint -verbose 2>&1 | grep 'was disabled' # nada impresso = regras ativas
actionlint # exit 0 so vale depois da linha acima
```
### Backend
```bash
# Which tag is actually being served right now? (this is your rollback target)
# Accept ONLY immutable sha-* — `latest` was just re-pointed at the broken image (lesson 59).
docker inspect --format '{{.Config.Image}}' <container>
# Manual rollback (compose path varies by project)
export IMAGE_TAG=sha-<previous-short-sha>
docker compose -f <COMPOSE_PATH>/docker-compose.yml pull
docker compose -f <COMPOSE_PATH>/docker-compose.yml up -d --force-recreate
# NOT DONE YET — `up -d` exiting 0 only means Docker accepted the request (lesson 60).
# Wait for healthy, then re-run the same smoke you use for a forward deploy:
curl -fsS --max-time 10 https://<api-host>/health/ready | grep -q '"status":"ok"' \
&& echo "rollback re-smoked OK" \
|| echo "ROLLBACK DID NOT RESTORE SERVICE — production is down"
```
> Migrations are **not** reverted (`prisma migrate deploy` / `manage.py migrate` have no
> down step), so the previous image runs against the newer schema. Additive migrations
> survive that; destructive ones don't. See `cd-verification-and-rollback.md` §2.
### Frontend
```bash
# Same rule: immutable tag only. Note the environment suffix on build-time-baked
# images — without it a fast-forward merge makes prod overwrite staging (lesson 64).
export IMAGE_TAG=sha-<previous-short-sha>
docker compose -f <COMPOSE_PATH>/docker-compose.yml pull
docker compose -f <COMPOSE_PATH>/docker-compose.yml up -d --force-recreate
# ...then re-smoke, as above.
# Check VITE_* embedded in JS (inside the image)
docker exec <container> sh -c "grep -r '<your-domain>' /usr/share/nginx/html/assets/*.js | head -5"
```
> Stronger check: grep the bundle **as served over HTTPS** rather than the one inside the
> container — it proves what the user's browser actually receives, including any stale
> upstream in the reverse-proxy pool. Recipe in `cd-pipeline-pitfalls.md` §1 step 4.
---
## Pipeline Files
### Backend
| File | Description |
| ------------------------------------- | --------------------------------------- |
| `.github/workflows/ci.yml` | CI pipeline (lint + test) for PRs |
| `.github/workflows/cd-staging.yml` | CD pipeline for staging (push develop) |
| `.github/workflows/cd-production.yml` | CD pipeline for production (tags v\*) |
| `Dockerfile` or `infra/*/Dockerfile` | Multi-stage build (path varies by project) |
| `infra/*/docker-compose.yml` | Compose with GHCR image (path varies) |
| `src/env.ts` | Zod validation of env vars |
### Frontend
| File | Description |
| ------------------------------------- | --------------------------------------- |
| `.github/workflows/ci.yml` | CI pipeline (lint + typecheck + test) |
| `.github/workflows/cd-staging.yml` | CD pipeline for staging (push develop) |
| `.github/workflows/cd-production.yml` | CD pipeline for production (tags v\*) |
| `infra/<web>/Dockerfile` | Multi-stage build (node + nginx; path varies by project) |
| `infra/<web>/docker-compose.yml` | Compose with GHCR image |
| `infra/<web>/nginx.conf` | nginx config (SPA try_files) |