From 80357cb0f64793b86581c5f93da64df7f6aa6115 Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 22:41:09 -0300 Subject: [PATCH 1/4] =?UTF-8?q?Faz=20o=20CI=20executar=20a=20imagem,=20n?= =?UTF-8?q?=C3=A3o=20s=C3=B3=20build=C3=A1-la?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O job docker buildava a imagem de produção e nunca a subia, então tudo que só falha em runtime passava batido: entrypoint, db:prepare, permissões do usuário não-root, healthcheck, publicação de porta, variável de ambiente faltando. Depois da #77 isso ficou pior: o deploy passou a ter dois processos, e o segundo — o worker do Solid Queue — nunca foi exercitado por nada automatizado. Um worker que sobe e não consome nada é invisível, porque a tela responde normalmente e a entrega simplesmente não acontece. O job agora sobe db + web + worker com `docker compose up --wait` e verifica quatro coisas: a stack fica saudável; /acessibilidade responde 200 pela porta publicada (de fora do container, o que o healthcheck interno não cobre); o log traz a linha de chave efêmera do entrypoint; e o worker executa um job enfileirado pelo web. Sobre a premissa da issue: ela dizia que os dois bugs de build já vividos por este projeto (CRLF em bin/*, plataforma ausente no Gemfile.lock) teriam sido pegos por um smoke test. Não é verdade — o Dockerfile executa ./bin/rails assets:precompile, então os dois quebravam o próprio build, que o CI já validava. O argumento que se sustenta é o vizinho: bin/docker-entrypoint depende da mesma normalização de LF e só roda quando o container SOBE, então um problema ali passaria por um build verde. Corrigi o texto no README e no comentário do ci.yml em vez de repetir a premissa. Decisões: O build ficou em duas etapas. A primeira carrega a imagem no daemon local (load) para o smoke test rodar contra o artefato de verdade; a segunda publica, e como o contexto é o mesmo acerta o cache inteiro, custando segundos. O ganho não é tempo: é que uma imagem que não sobe nunca chega ao registry. Para o compose usar essa imagem em vez de buildar de novo, web e worker ganharam `image: ${APP_IMAGE:-task-keeper-api:local}`. Buildar duas vezes o mesmo artefato dobraria o job. Efeito colateral local: quem rodar `docker compose up` sem --build com uma imagem antiga em disco sobe a antiga — o README já manda usar --build. SECRET_KEY_BASE vazio no job é o ponto, não descuido: é o que exercita o caminho de chave efêmera que nasceu na v2.0.0. O env do workflow descreve o ambiente de teste, então o job sobrescreve as três variáveis que não valem para uma stack de produção. O job enfileirado é WebhookDeliveryJob para a assinatura 0, que não existe: WebhookDelivery recusa antes de tocar a rede. O que se verifica é o percurso (web enfileira, Postgres guarda, worker executa), não a entrega. Verificado rodando os passos exatos do workflow na máquina local, contra a imagem buildada do Dockerfile: `up --wait` verde nos três serviços, curl 200, grep da chave efêmera, e o log do worker com "Performed WebhookDeliveryJob ... in 15.31ms" para o job enfileirado pelo web. Gates de sempre no container: RuboCop 110 arquivos sem ofensas, zeitwerk:check limpo, RSpec 384 exemplos e 0 falhas. Vai junto a atualização de um comentário do config/queue.yml que a #78 deixou desatualizado: já não é só webhook que roda em background. Closes #81 Co-Authored-By: Claude Opus 5 --- .env.example | 8 +++ .github/workflows/ci.yml | 116 +++++++++++++++++++++++++++++++++++++-- README.md | 27 +++++++-- config/queue.yml | 6 +- docker-compose.yml | 16 ++++++ 5 files changed, 161 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 59245c7..1e38383 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,11 @@ RAILS_MAX_THREADS=5 # config/queue.yml e o serviço "worker" no docker-compose.yml). Um só # basta para o volume atual; existe para escalar sem editar arquivo. JOB_CONCURRENCY=1 + +# Opcional — nome/tag da imagem que os serviços web e worker usam no +# docker-compose.yml. O default (task-keeper-api:local) é o que o +# `docker compose up --build` monta. Existe para o smoke test do CI poder +# apontar o compose para uma imagem já buildada, em vez de buildar de novo +# (ver .github/workflows/ci.yml); também serve para rodar a stack contra +# uma imagem publicada, ex.: ghcr.io/hirley/task_keeper_api:2.0.1. +APP_IMAGE= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a985b0..22ca0c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: run: bundle exec rspec docker: - name: Build da imagem Docker + name: Build e smoke test da imagem Docker runs-on: ubuntu-latest needs: [rubocop, rspec] # Só precisa de permissão de escrita no registry (packages: write) para @@ -95,15 +95,115 @@ jobs: permissions: contents: read packages: write + # As três anulam, só dentro deste job, o `env:` do workflow — que + # descreve o ambiente de TESTE, e aqui o que sobe é a imagem de + # PRODUÇÃO, pelo docker-compose.yml: + # + # * APP_IMAGE aponta o compose pra imagem que o passo de build + # acabou de carregar no daemon, em vez de buildar tudo de novo + # (ver o comentário da âncora x-app-image no docker-compose.yml); + # * SECRET_KEY_BASE vazio é o ponto do smoke test, não descuido: é + # o que exercita o caminho de chave efêmera do entrypoint, que + # nasceu na v2.0.0 e nunca teve verificação automatizada; + # * DB_NAME volta pro banco de produção — o global aponta pro banco + # de teste, que não existe nesta stack. + env: + APP_IMAGE: task-keeper-api:ci + SECRET_KEY_BASE: '' + DB_NAME: task_keeper_api_production steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Build em duas etapas de propósito. Esta carrega a imagem no daemon + # local (load) para o smoke test rodar contra o artefato de verdade, + # antes de qualquer publicação — uma imagem que não sobe não deve + # chegar ao registry. A etapa que publica vem depois de tudo passar, + # e acerta o cache do gha, então custa segundos. + - name: Build da imagem (carrega no daemon local, pro smoke test) + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: ${{ env.APP_IMAGE }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # O CI buildava a imagem e nunca a executava, então tudo que só falha + # em runtime passava batido: entrypoint, db:prepare, permissões do + # usuário não-root, healthcheck, publicação de porta, variável de + # ambiente faltando. + # + # Os dois bugs de build que este projeto já teve (CRLF em bin/*, + # plataforma ausente no Gemfile.lock) NÃO são exemplo disso — os + # dois quebravam durante o próprio build, porque o Dockerfile + # executa ./bin/rails assets:precompile, e o job de build já os + # pegava. O vizinho deles é que interessa: bin/docker-entrypoint + # depende da mesma normalização de LF e só roda quando o container + # SOBE, então um problema ali passaria por um build verde. + # + # --wait só volta quando o banco e o web estão SAUDÁVEIS pelo + # healthcheck do próprio Dockerfile, e o worker rodando; se algum + # container morre no boot, o passo falha aqui. + - name: Sobe a stack de produção (banco + web + worker) + run: docker compose up --detach --wait --wait-timeout 240 + + # O healthcheck já bate em /acessibilidade de dentro do container. + # Este curl é de fora: verifica também a publicação da porta. + - name: A rota pública responde pela porta publicada + run: curl --fail --silent --show-error --max-time 10 -o /dev/null http://localhost:3000/acessibilidade + + - name: O entrypoint gerou a chave efêmera (caminho sem SECRET_KEY_BASE) + run: docker compose logs web | grep -q 'gerando uma chave' + + # Desde a adoção do Solid Queue o deploy tem DOIS processos, e o + # segundo nunca foi exercitado por nada automatizado. Um worker que + # sobe e não consome nada é invisível: a tela responde normalmente e + # a entrega simplesmente não acontece. + # + # O job escolhido não tem efeito colateral nenhum — a assinatura 0 + # não existe, então WebhookDelivery recusa antes de tocar a rede + # (ver o contrato de retorno em WebhookDelivery#entregar). O que se + # verifica aqui é o percurso: o web enfileira, o Postgres guarda, o + # worker reclama e executa. + # + # O -e SECRET_KEY_BASE é necessário porque `docker compose exec` não + # passa pelo ENTRYPOINT, então não herda a chave efêmera que o + # entrypoint gerou pro processo do Puma. + - name: O worker consome um job da fila + run: | + docker compose exec -T -e SECRET_KEY_BASE=smoke-test web \ + bin/rails runner 'WebhookDeliveryJob.perform_later(0, "smoke_test", {})' + + for _ in $(seq 1 30); do + if docker compose logs worker | grep -q 'Performed WebhookDeliveryJob'; then + echo "O worker executou o job enfileirado pelo web." + exit 0 + fi + sleep 2 + done + + echo "::error::o worker não executou o job em 60s" + exit 1 + + # Sem isto, um smoke test vermelho não diz nada além de "falhou": o + # container que morreu já foi derrubado pelo passo seguinte. + - name: Estado e logs da stack (só quando algum passo acima falha) + if: failure() + run: | + docker compose ps --all + docker compose logs --no-color + + - name: Derruba a stack + if: always() + run: docker compose down --volumes --remove-orphans + # Só publica quando o CI roda por push (ex.: merge em main) — em - # pull_request o job continua só validando que o Dockerfile builda, - # sem publicar imagem de branch/fork ainda não revisado. + # pull_request o job continua só validando que o Dockerfile builda e + # que a imagem sobe, sem publicar imagem de branch/fork ainda não + # revisado. - name: Login no GitHub Container Registry if: github.event_name == 'push' uses: docker/login-action@v3 @@ -160,7 +260,14 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - - name: Build da imagem (publica só em push; em pull_request só valida o build) + # Segunda etapa do build: mesmo contexto, então acerta inteiro o + # cache gravado pelo build local acima — o que ela acrescenta são as + # tags/labels definitivos e o push. Sem cache-to justamente por isso: + # gravar o mesmo cache de novo só custaria tempo. + # + # Publica só em push (ex.: merge em main); em pull_request continua + # sendo apenas mais uma validação. + - name: Publica a imagem (só em push; em pull_request só revalida o build) uses: docker/build-push-action@v6 with: context: . @@ -168,4 +275,3 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 9864959..a86b1d1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Aplicação Ruby on Rails full-stack (API REST + interface web) para times pequenos organizarem e acompanharem demandas do dia a dia: quem é responsável por quê, o que está atrasado, e um jeito rápido de saber "o que precisa da minha atenção agora?". -Três papéis com permissões diferentes (executor/líder/admin), autenticação e autorização (Devise + CanCanCan), notificação automática de atraso via Telegram, webhooks de saída (Slack/Teams/Discord/n8n), relatório semanal em PDF, e uma API JSON versionada ao lado da tela web — tudo com suíte de testes automatizados, CI (RuboCop + RSpec + build/publish da imagem Docker) e deploy em container. +Três papéis com permissões diferentes (executor/líder/admin), autenticação e autorização (Devise + CanCanCan), notificação automática de atraso via Telegram, webhooks de saída (Slack/Teams/Discord/n8n), relatório semanal em PDF, e uma API JSON versionada ao lado da tela web — tudo com suíte de testes automatizados, CI (RuboCop + RSpec + build, smoke test e publish da imagem Docker) e deploy em container. **Rodando em produção:** ver seção "Docker" — imagem publicada automaticamente em `ghcr.io/hirley/task_keeper_api` a cada merge em `main`. @@ -12,7 +12,7 @@ Três papéis com permissões diferentes (executor/líder/admin), autenticação - HAML + Bootstrap (interface web) + Ransack (filtro/ordenação — ver "Identidade visual e busca/paginação") - Prawn + prawn-table (PDF do relatório semanal — ver "Relatório semanal") - RSpec + FactoryBot + Shoulda Matchers (testes) · RuboCop (estilo) -- Docker + GitHub Actions (CI: lint, testes, build e publish da imagem) +- Docker + GitHub Actions (CI: lint, testes, build, smoke test e publish da imagem) ## Regras de negócio @@ -108,7 +108,7 @@ Este projeto não tem `config/master.key`/`config/credentials.yml.enc`, então ` **Sobre a plataforma do `Gemfile.lock`**: o lockfile deste projeto foi gerado originalmente numa máquina Windows — a seção `PLATFORMS` só tem `x64-mingw-ucrt`, sem a plataforma Linux. Sem isso, `bundle install` falha dentro de um container Linux ao tentar resolver as gems com extensão nativa (`pg`, `nokogiri`). O `Dockerfile` já corrige isso sozinho (roda `bundle lock --add-platform x86_64-linux` antes do `bundle install`, dentro da própria imagem), então não é preciso fazer nada manualmente por causa disso — mas é bom saber que esse ajuste existe, caso apareça algum erro de plataforma ao rodar `bundle install` fora do Docker também (nesse caso, `bundle lock --add-platform x86_64-linux` resolve, e o mesmo vale se você desenvolver num Mac Apple Silicon: `bundle lock --add-platform arm64-darwin`). -O build é validado automaticamente a cada push/PR pelo CI (ver seção "Integração contínua"). Durante o desenvolvimento, dois bugs reais de build já apareceram e foram corrigidos: +O build é validado automaticamente a cada push/PR pelo CI, que também **sobe a imagem e verifica que ela funciona** (ver seção "Integração contínua"). Durante o desenvolvimento, dois bugs reais de build já apareceram e foram corrigidos: 1. **`COPY . .` sobrescrevendo o `Gemfile.lock` corrigido**: rodava depois do `bundle lock --add-platform`, apagando silenciosamente o ajuste de plataforma antes do `bootsnap precompile app/ lib/` seguinte (`bundle exec` revalida a plataforma a cada chamada). Corrigido copiando o projeto inteiro antes de mexer no `Gemfile.lock`. 2. **CRLF em `bin/*`**: quem desenvolve no Windows normalmente tem `core.autocrlf=true` no Git, que converte os scripts de `bin/` (LF no repositório) para CRLF no checkout local; como `docker build` copia o contexto direto do disco (não do objeto Git), o CRLF ia parar no container e o shebang `#!/usr/bin/env ruby` de `bin/rails` virava `ruby\r` — `env: 'ruby\r': No such file or directory`. Corrigido normalizando `bin/*` para LF em tempo de build (`sed -i 's/\r$//' bin/*`, logo após o `COPY . .`), além de um `.gitattributes` (`* text=auto eol=lf`) pra evitar isso em checkouts novos. @@ -119,7 +119,24 @@ O build é validado automaticamente a cada push/PR pelo CI (ver seção "Integra - **rubocop** — `bundle exec rubocop` (usa o `.rubocop.yml` já existente no repositório); - **rspec** — sobe um serviço `postgres:16-alpine`, roda `bin/rails db:prepare`, depois `bin/rails zeitwerk:check` (eager load isolado num processo à parte, só pra pegar erro de autoload cedo — ver comentário em `config/environments/test.rb` sobre por que isso não é feito via `config.eager_load = true` no ambiente de teste) e por fim `bundle exec rspec` contra o banco `task_keeper_api_test`; -- **docker** — builda a imagem de produção (`docker/build-push-action`); só roda depois que `rubocop` e `rspec` passam. Em pull request, só valida que o `Dockerfile` builda (sem publicar). Em push pra `main`, publica a imagem no GitHub Container Registry (`ghcr.io/hirley/task_keeper_api`), usando o `GITHUB_TOKEN` automático do Actions — não exige nenhum secret configurado manualmente. +- **docker** — builda a imagem de produção, **sobe a stack inteira e verifica que ela funciona**, e só então publica; roda depois que `rubocop` e `rspec` passam. Em pull request, valida o build e o smoke test sem publicar. Em push pra `main`, publica a imagem no GitHub Container Registry (`ghcr.io/hirley/task_keeper_api`), usando o `GITHUB_TOKEN` automático do Actions — não exige nenhum secret configurado manualmente. + + **O smoke test** existe porque validar que a imagem *builda* não diz nada sobre ela *subir*: entrypoint, `db:prepare`, permissões do usuário não-root, healthcheck, publicação de porta e variável de ambiente faltando só falham em runtime. + + (Os dois bugs listados na seção "Docker" **não** são exemplo disso — os dois quebravam durante o `docker build`, porque o Dockerfile executa `./bin/rails assets:precompile`, e o job de build já os pegaria. O caso interessante é o vizinho: `bin/docker-entrypoint` também depende da normalização de LF, e ele só roda quando o container **sobe** — um problema ali passaria por um build verde.) + + O job sobe `db` + `web` + `worker` com `docker compose up --wait` (que só volta quando os healthchecks passam) e checa quatro coisas: + + | Verificação | O que quebraria sem ela | + |---|---| + | `--wait` volta sem erro | container que morre no boot, entrypoint quebrado, healthcheck quebrado, `db:prepare` falhando | + | `curl` em `/acessibilidade` de fora do container | porta não publicada, Puma escutando só em loopback | + | a linha de chave efêmera no log do entrypoint | o caminho "sem `SECRET_KEY_BASE`" da v2.0.0, que nada exercitava | + | o `worker` executa um job enfileirado pelo `web` | worker que sobe e não consome nada — invisível, porque a tela responde normal e a entrega só não acontece | + + O `SECRET_KEY_BASE` do job é vazio **de propósito**: é o que faz o entrypoint gerar a chave efêmera. O job enfileirado é `WebhookDeliveryJob` para uma assinatura inexistente, que `WebhookDelivery` recusa antes de tocar a rede — o que se verifica é o percurso (web → Postgres → worker), não a entrega. + + A imagem é buildada uma vez e carregada no daemon local (`load`), o smoke test roda contra ela, e só depois vem a etapa que publica — que acerta o cache inteiro e custa segundos. Uma imagem que não sobe nunca chega ao registry. As tags da imagem dependem do que disparou o build: @@ -141,7 +158,7 @@ O build é validado automaticamente a cada push/PR pelo CI (ver seção "Integra ⚠️ **Passo manual único**: por padrão, um pacote novo no GHCR nasce privado, mesmo em repositório público — depois do primeiro push em `main` que publicar a imagem, é preciso ir em *Package settings* (na página do pacote em `github.com/Hirley?tab=packages`) e trocar a visibilidade pra pública, se quiser puxar a imagem (`docker pull`) sem autenticação. -`SECRET_KEY_BASE` no workflow é um valor fixo só para o boot da aplicação em CI (não é usado em nenhum ambiente real — produção continua exigindo a variável de ambiente própria, como descrito na seção "Docker"). As demais variáveis de banco seguem o mesmo padrão de `.env.example`/`config/database.yml`. +`SECRET_KEY_BASE` no workflow é um valor fixo só para o boot da aplicação em CI (não é usado em nenhum ambiente real — produção continua exigindo a variável de ambiente própria, como descrito na seção "Docker"). As demais variáveis de banco seguem o mesmo padrão de `.env.example`/`config/database.yml`. O job **docker** sobrescreve três delas, porque o `env:` do workflow descreve o ambiente de *teste* e ali o que sobe é a imagem de *produção* — ver o comentário no próprio `ci.yml`. ## Fluxo de desenvolvimento com agentes diff --git a/config/queue.yml b/config/queue.yml index 7db3348..ba12e55 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -1,8 +1,10 @@ # Configuração do Solid Queue (ver Gemfile e a seção "Webhooks de saída" # do README). Só vale onde o adapter é :solid_queue — hoje, produção. # -# Um worker só, com 3 threads, é folgado para o volume deste projeto: a -# única coisa que roda em background é a entrega de webhooks. JOB_CONCURRENCY +# Um worker só, com 3 threads, é folgado para o volume deste projeto: o +# que roda em background é a entrega de webhooks, o envio do relatório +# semanal por Telegram e o link de redefinição de senha por Telegram — +# tudo disparado por ação humana numa equipe pequena. JOB_CONCURRENCY # existe para escalar sem mexer no arquivo, se um dia isso mudar. default: &default dispatchers: diff --git a/docker-compose.yml b/docker-compose.yml index c86da8b..0b4d139 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,20 @@ x-app-build: &app-build args: RUBY_VERSION: ${RUBY_VERSION:-4.0.6} +# Nome da imagem que web e worker usam. Com `--build` o compose builda e +# marca a imagem com este nome; sem `--build`, ele reaproveita uma imagem +# já existente com esse nome em vez de buildar. +# +# É esse segundo comportamento que o smoke test do CI usa: lá a imagem já +# foi buildada pelo passo anterior do job (com cache), e APP_IMAGE aponta +# pra ela — buildar de novo aqui dobraria o tempo do job pra produzir +# exatamente o mesmo artefato. Ver .github/workflows/ci.yml. +# +# Efeito colateral local: se você já tem uma task-keeper-api:local antiga +# e roda `docker compose up` sem `--build`, sobe a antiga. O README manda +# usar `--build`, que é o que resolve. +x-app-image: &app-image ${APP_IMAGE:-task-keeper-api:local} + x-app-env: &app-env # Sem valor default de propósito: quando esta variável chega vazia, # bin/docker-entrypoint gera uma chave efêmera só para a execução @@ -68,6 +82,7 @@ services: web: build: *app-build + image: *app-image ports: - "${PORT:-3000}:3000" environment: *app-env @@ -79,6 +94,7 @@ services: worker: build: *app-build + image: *app-image command: ["./bin/jobs"] environment: *app-env volumes: From 0ce4814ee12c187039a98cbf5a5c7d6fd9f66e78 Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 23:04:11 -0300 Subject: [PATCH 2/4] =?UTF-8?q?Devolve=20o=20bit=20de=20execu=C3=A7=C3=A3o?= =?UTF-8?q?=20a=20bin/jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O smoke test do commit anterior falhou na primeira execução com "container task_keeper_api-worker-1 exited (126)" — 126 é "comando encontrado, mas não executável". bin/jobs entrou no repositório como 100644, enquanto todos os outros scripts de bin/ são 100755. O arquivo foi criado no Windows na #77, e o sistema de arquivos de lá não tem bit de execução, então o Git registrou o modo sem ele. Um `docker build` nesta máquina não percebe: o contexto vem do disco, onde tudo parece executável — foi por isso que a verificação da #77 passou aqui. Um checkout num runner Linux respeita o modo do índice, e aí o worker não sobe. Ou seja: a imagem publicada em main a partir da #87 builda perfeitamente e não tem worker. Como o web responde normalmente e a falha é do outro processo, o sintoma seria "os jobs não saem da fila" — exatamente o modo de falha silencioso que a #77 veio eliminar. Duas camadas, mesmo critério já usado para o CRLF: * `git update-index --chmod=+x bin/jobs` corrige a causa, no índice; * `chmod +x bin/*` no Dockerfile, ao lado do `sed` que normaliza LF, protege a imagem do próximo script criado no Windows. A armadilha foi para o CLAUDE.md, junto com outra que apareceu na verificação: exportar MSYS_NO_PATHCONV=1 no shell inteiro faz o curl do Git Bash falhar com "(23)" em `-o /dev/null`, o que parece erro da aplicação e não é. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 15 +++++++++------ CLAUDE.md | 4 +++- Dockerfile | 12 +++++++++++- README.md | 5 ++++- bin/jobs | 0 5 files changed, 27 insertions(+), 9 deletions(-) mode change 100644 => 100755 bin/jobs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22ca0c9..9dde101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,13 +136,16 @@ jobs: # usuário não-root, healthcheck, publicação de porta, variável de # ambiente faltando. # - # Os dois bugs de build que este projeto já teve (CRLF em bin/*, + # Os dois primeiros bugs de build deste projeto (CRLF em bin/*, # plataforma ausente no Gemfile.lock) NÃO são exemplo disso — os - # dois quebravam durante o próprio build, porque o Dockerfile - # executa ./bin/rails assets:precompile, e o job de build já os - # pegava. O vizinho deles é que interessa: bin/docker-entrypoint - # depende da mesma normalização de LF e só roda quando o container - # SOBE, então um problema ali passaria por um build verde. + # dois quebravam o próprio build, porque o Dockerfile executa + # ./bin/rails assets:precompile, e o job de build já os pegava. + # + # O terceiro é: bin/jobs entrou no repositório sem bit de execução + # (criado no Windows, onde o sistema de arquivos não tem esse bit), + # a imagem buildava perfeitamente e o worker morria com exit 126 ao + # subir. Este passo achou isso na primeira vez que rodou, com a + # imagem publicada em main já quebrada havia dois merges. # # --wait só volta quando o banco e o web estão SAUDÁVEIS pelo # healthcheck do próprio Dockerfile, e o worker rodando; se algum diff --git a/CLAUDE.md b/CLAUDE.md index 9081389..17399b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,9 @@ Se você mexeu nessas áreas, suba a app e verifique de verdade — console sem **`db/schema.rb` regenerado no container vem com ruído.** O Postgres 16 do container acrescenta `enable_extension "pg_catalog.plpgsql"` e reordena as opções da coluna `events`. Para migration que só mexe em dados, edite **apenas** a linha `define(version:)` à mão e descarte o resto. -**Git Bash converte caminho em argumento de `docker`.** `-w /app` vira `C:/Program Files/Git/app`. Prefixe os comandos com `MSYS_NO_PATHCONV=1`. +**Git Bash converte caminho em argumento de `docker`.** `-w /app` vira `C:/Program Files/Git/app`. Prefixe os comandos com `MSYS_NO_PATHCONV=1`. Cuidado ao exportar essa variável para o shell inteiro: aí o `curl` do Git Bash também deixa de converter, e `-o /dev/null` falha com `curl: (23)` — parece erro da aplicação e não é. + +**Script novo em `bin/` nasce sem bit de execução.** O sistema de arquivos do Windows não tem esse bit, então o Git registra `100644`. Um `docker build` daqui não percebe (o contexto vem do disco, onde tudo parece executável) e um checkout Linux respeita o índice — foi assim que `bin/jobs` derrubou o container do worker com `exit 126`, com a imagem de `main` quebrada por dois merges. Depois de criar um script em `bin/`, rode `git update-index --chmod=+x bin/` e confira com `git ls-files -s bin/`. O `chmod +x bin/*` do Dockerfile é rede de segurança da imagem, não do repositório. **Cops que já morderam:** diff --git a/Dockerfile b/Dockerfile index 8570410..cacbfb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,7 +71,17 @@ COPY . . # não corrige um checkout já existente sem um passo manual do usuário # (`git add --renormalize .` ou re-clonar) — este `sed` cobre esse caso # sem depender disso. -RUN sed -i 's/\r$//' bin/* +# +# O chmod +x vai junto pelo mesmo motivo, e a falta dele já custou uma +# imagem quebrada: quem cria um script em bin/ no Windows não tem bit de +# execução no sistema de arquivos, então o Git registra o arquivo como +# 100644 — foi o que aconteceu com bin/jobs. O `docker build` no Windows +# não percebe (o contexto vem do disco, onde tudo parece executável), mas +# o checkout num runner Linux respeita o modo do índice, e o container do +# worker morria com exit 126 ("command not executable"). Corrigido no +# índice também (`git update-index --chmod=+x`); esta linha é a rede de +# segurança para o próximo arquivo criado no Windows. +RUN sed -i 's/\r$//' bin/* && chmod +x bin/* # O Gemfile.lock deste projeto foi gerado originalmente numa máquina # Windows — a seção PLATFORMS só tinha "x64-mingw-ucrt", sem a diff --git a/README.md b/README.md index a86b1d1..67d8c05 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,9 @@ O build é validado automaticamente a cada push/PR pelo CI, que também **sobe a 1. **`COPY . .` sobrescrevendo o `Gemfile.lock` corrigido**: rodava depois do `bundle lock --add-platform`, apagando silenciosamente o ajuste de plataforma antes do `bootsnap precompile app/ lib/` seguinte (`bundle exec` revalida a plataforma a cada chamada). Corrigido copiando o projeto inteiro antes de mexer no `Gemfile.lock`. 2. **CRLF em `bin/*`**: quem desenvolve no Windows normalmente tem `core.autocrlf=true` no Git, que converte os scripts de `bin/` (LF no repositório) para CRLF no checkout local; como `docker build` copia o contexto direto do disco (não do objeto Git), o CRLF ia parar no container e o shebang `#!/usr/bin/env ruby` de `bin/rails` virava `ruby\r` — `env: 'ruby\r': No such file or directory`. Corrigido normalizando `bin/*` para LF em tempo de build (`sed -i 's/\r$//' bin/*`, logo após o `COPY . .`), além de um `.gitattributes` (`* text=auto eol=lf`) pra evitar isso em checkouts novos. +3. **`bin/jobs` sem bit de execução**: quem cria um script em `bin/` no Windows não tem bit de execução no sistema de arquivos, então o Git registra o arquivo como `100644` — foi o que aconteceu com `bin/jobs` quando o Solid Queue entrou. Um `docker build` **no Windows** não percebe (o contexto vem do disco, onde tudo parece executável), mas o checkout num runner Linux respeita o modo do índice, e o container do `worker` morria com `exit 126` assim que subia. A imagem buildava, e a aplicação buildada não tinha worker. Corrigido no índice (`git update-index --chmod=+x bin/jobs`) e com um `chmod +x bin/*` em tempo de build, ao lado do `sed` acima. + + Este é o único dos três que **não** quebrava o `docker build` — os outros dois quebravam, porque o Dockerfile executa `./bin/rails assets:precompile`. Ele só aparecia ao *rodar* a imagem, e foi encontrado pelo smoke test descrito em "Integração contínua", na primeira vez que ele rodou. ## Integração contínua @@ -123,7 +126,7 @@ O build é validado automaticamente a cada push/PR pelo CI, que também **sobe a **O smoke test** existe porque validar que a imagem *builda* não diz nada sobre ela *subir*: entrypoint, `db:prepare`, permissões do usuário não-root, healthcheck, publicação de porta e variável de ambiente faltando só falham em runtime. - (Os dois bugs listados na seção "Docker" **não** são exemplo disso — os dois quebravam durante o `docker build`, porque o Dockerfile executa `./bin/rails assets:precompile`, e o job de build já os pegaria. O caso interessante é o vizinho: `bin/docker-entrypoint` também depende da normalização de LF, e ele só roda quando o container **sobe** — um problema ali passaria por um build verde.) + Dos três bugs listados na seção "Docker", os dois primeiros **não** são exemplo disso — quebravam durante o `docker build`, e o job de build já os pegava. O terceiro é: `bin/jobs` sem bit de execução buildava perfeitamente e derrubava o `worker` com `exit 126` ao subir. **Foi encontrado por este smoke test na primeira vez que ele rodou**, num repositório onde a imagem publicada em `main` já estava assim havia dois merges. O job sobe `db` + `web` + `worker` com `docker compose up --wait` (que só volta quando os healthchecks passam) e checa quatro coisas: diff --git a/bin/jobs b/bin/jobs old mode 100644 new mode 100755 From 7331f6165ba0014e8ae6b69d2cd0a08729e0a19c Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 23:11:04 -0300 Subject: [PATCH 3/4] =?UTF-8?q?Pede=20--wait=20s=C3=B3=20para=20os=20servi?= =?UTF-8?q?=C3=A7os=20que=20t=C3=AAm=20healthcheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O smoke test falhou com "container task_keeper_api-worker-1 has no healthcheck configured". O `--wait` do compose exige healthcheck em todo serviço nomeado, e o worker não tem um: ele não serve HTTP, então o HEALTHCHECK do Dockerfile (curl na 3000) está desligado nele de propósito. Isso não apareceu na verificação local porque o compose do Docker Desktop aceita a mesma combinação e reporta o serviço como "Healthy" — o do runner trata como erro. É a segunda vez neste PR que o ambiente Windows esconde uma diferença que só o Linux mostra. Não inventei um healthcheck de processo para o worker só para satisfazer o --wait: seria uma verificação de pulso, e já existe uma de trabalho. Agora o job espera db e web ficarem saudáveis, sobe o worker em seguida, e quem prova que ele está vivo continua sendo "O worker consome um job da fila". Entre os dois entrou uma checagem de estado do container, com retry curto. Ela não acrescenta cobertura — acrescenta diagnóstico: um worker que morre no boot passa a falhar em ~2s com o exit code na mensagem, em vez de virar um timeout de 60s dizendo apenas "não consumiu o job". Era esse o sintoma do bin/jobs sem bit de execução, no commit anterior. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++---- README.md | 7 ++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dde101..6066a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,11 +147,53 @@ jobs: # subir. Este passo achou isso na primeira vez que rodou, com a # imagem publicada em main já quebrada havia dois merges. # - # --wait só volta quando o banco e o web estão SAUDÁVEIS pelo - # healthcheck do próprio Dockerfile, e o worker rodando; se algum - # container morre no boot, o passo falha aqui. - - name: Sobe a stack de produção (banco + web + worker) - run: docker compose up --detach --wait --wait-timeout 240 + # --wait é pedido só para db e web, de propósito: ele exige que todo + # serviço nomeado tenha healthcheck, e falha com "container ... has + # no healthcheck configured" para os que não têm. O worker não tem — + # não serve HTTP nenhum, e o healthcheck do Dockerfile (curl na + # 3000) está desligado nele por isso (ver docker-compose.yml). + # + # Não inventamos um healthcheck de processo para o worker só para + # satisfazer o --wait: quem prova que ele está vivo é o passo "O + # worker consome um job da fila", mais abaixo, que mede o trabalho + # em vez do pulso. + # + # (O compose do Docker Desktop aceita --wait com healthcheck + # desligado e reporta o serviço como "Healthy"; o do runner trata + # como erro. Foi o que quebrou a primeira versão deste job.) + - name: Sobe o banco e o web, e espera ficarem saudáveis + run: docker compose up --detach --wait --wait-timeout 240 db web + + - name: Sobe o worker + run: docker compose up --detach worker + + # Falha rápido e com o motivo na mão. Sem isto, um worker que morre + # no boot só apareceria 60s depois, como "não consumiu o job" — foi + # exatamente o que aconteceu com o bin/jobs sem bit de execução, que + # este smoke test pegou (exit 126) na primeira vez que rodou. + - name: O worker continua de pé depois de subir + run: | + cid=$(docker compose ps --quiet worker) + + for _ in $(seq 1 10); do + estado=$(docker inspect --format '{{.State.Status}}' "$cid") + + if [ "$estado" = "running" ]; then + echo "worker de pé" + exit 0 + fi + + if [ "$estado" = "exited" ] || [ "$estado" = "dead" ]; then + codigo=$(docker inspect --format '{{.State.ExitCode}}' "$cid") + echo "::error::o worker morreu no boot (estado: $estado, exit code: $codigo)" + exit 1 + fi + + sleep 2 + done + + echo "::error::o worker não chegou a rodar em 20s" + exit 1 # O healthcheck já bate em /acessibilidade de dentro do container. # Este curl é de fora: verifica também a publicação da porta. diff --git a/README.md b/README.md index 67d8c05..17565be 100644 --- a/README.md +++ b/README.md @@ -128,15 +128,18 @@ O build é validado automaticamente a cada push/PR pelo CI, que também **sobe a Dos três bugs listados na seção "Docker", os dois primeiros **não** são exemplo disso — quebravam durante o `docker build`, e o job de build já os pegava. O terceiro é: `bin/jobs` sem bit de execução buildava perfeitamente e derrubava o `worker` com `exit 126` ao subir. **Foi encontrado por este smoke test na primeira vez que ele rodou**, num repositório onde a imagem publicada em `main` já estava assim havia dois merges. - O job sobe `db` + `web` + `worker` com `docker compose up --wait` (que só volta quando os healthchecks passam) e checa quatro coisas: + O job sobe `db` + `web` + `worker` e checa cinco coisas: | Verificação | O que quebraria sem ela | |---|---| - | `--wait` volta sem erro | container que morre no boot, entrypoint quebrado, healthcheck quebrado, `db:prepare` falhando | + | `up --wait db web` volta sem erro | container que morre no boot, entrypoint quebrado, healthcheck quebrado, `db:prepare` falhando | | `curl` em `/acessibilidade` de fora do container | porta não publicada, Puma escutando só em loopback | | a linha de chave efêmera no log do entrypoint | o caminho "sem `SECRET_KEY_BASE`" da v2.0.0, que nada exercitava | + | o `worker` ainda está `running` depois de subir | worker que morre no boot — dá o exit code na hora, em vez de virar um timeout obscuro | | o `worker` executa um job enfileirado pelo `web` | worker que sobe e não consome nada — invisível, porque a tela responde normal e a entrega só não acontece | + O `--wait` é pedido só para `db` e `web`: ele exige healthcheck em todo serviço nomeado, e o `worker` não tem um (não serve HTTP — ver `docker-compose.yml`). Não inventamos um healthcheck de processo só para satisfazê-lo; quem prova que o worker está vivo é a última linha da tabela, que mede o trabalho em vez do pulso. + O `SECRET_KEY_BASE` do job é vazio **de propósito**: é o que faz o entrypoint gerar a chave efêmera. O job enfileirado é `WebhookDeliveryJob` para uma assinatura inexistente, que `WebhookDelivery` recusa antes de tocar a rede — o que se verifica é o percurso (web → Postgres → worker), não a entrega. A imagem é buildada uma vez e carregada no daemon local (`load`), o smoke test roda contra ela, e só depois vem a etapa que publica — que acerta o cache inteiro e custa segundos. Uma imagem que não sobe nunca chega ao registry. From b06cb1d2537c7a986da00a18e6e505b98f4bc823 Mon Sep 17 00:00:00 2001 From: Hirley Date: Tue, 1 Sep 2026 06:41:16 -0300 Subject: [PATCH 4/4] Tira o build redundante do job em pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com push=false, o passo que publica fazia um build inteiro — ainda que todo de cache — cujo resultado era descartado. O Dockerfile já foi validado pelo build local do passo anterior, e a imagem, pelo smoke test; refazer não acrescenta cobertura, só ~17s por PR. O passo de metadados continua rodando nos dois casos, então as regras de tag (a do latest e as de semver, que já falharam duas vezes neste projeto) seguem sendo exercitadas em pull request. Tempos medidos no runner, agora que dá para medir: o smoke test em si custa ~26s — 18s esperando db e web ficarem saudáveis, 3s subindo o worker, 3s para o job ir do web ao worker, 1s de teardown. O que pesa é o build com `load`, ~1m45s contra ~1m10s de um build que não exporta a imagem. Esses 35s são o preço de testar exatamente o artefato que vai ser publicado, e não uma cópia dele. Com isso o job fica em ~2m50s e o CI completo em ~4min, contra os ~3min que a issue estabeleceu como alvo. Registro a diferença em vez de esconder: o alvo foi escrito sem saber o custo do `load`, e o caminho para cumpri-lo seria publicar antes de testar, que é exatamente o que este PR existe para não fazer. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 12 ++++++++---- README.md | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6066a92..13fa623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,13 +310,17 @@ jobs: # tags/labels definitivos e o push. Sem cache-to justamente por isso: # gravar o mesmo cache de novo só custaria tempo. # - # Publica só em push (ex.: merge em main); em pull_request continua - # sendo apenas mais uma validação. - - name: Publica a imagem (só em push; em pull_request só revalida o build) + # Não roda em pull_request: com push=false ela seria um build inteiro + # (ainda que de cache) cujo resultado é descartado — o Dockerfile já + # foi validado pelo build local acima, e a imagem, pelo smoke test. + # O passo de metadados continua rodando em PR, então as regras de tag + # seguem sendo exercitadas nos dois casos. + - name: Publica a imagem no GHCR + if: github.event_name == 'push' uses: docker/build-push-action@v6 with: context: . - push: ${{ github.event_name == 'push' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/README.md b/README.md index 17565be..16c28e2 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,9 @@ O build é validado automaticamente a cada push/PR pelo CI, que também **sobe a O `SECRET_KEY_BASE` do job é vazio **de propósito**: é o que faz o entrypoint gerar a chave efêmera. O job enfileirado é `WebhookDeliveryJob` para uma assinatura inexistente, que `WebhookDelivery` recusa antes de tocar a rede — o que se verifica é o percurso (web → Postgres → worker), não a entrega. - A imagem é buildada uma vez e carregada no daemon local (`load`), o smoke test roda contra ela, e só depois vem a etapa que publica — que acerta o cache inteiro e custa segundos. Uma imagem que não sobe nunca chega ao registry. + A imagem é buildada uma vez e carregada no daemon local (`load`), o smoke test roda contra ela, e só depois vem a etapa que publica — que acerta o cache inteiro e custa segundos, e só existe em push (em pull request seria um build descartado). **Uma imagem que não sobe nunca chega ao registry.** + + Custo medido no runner: o smoke test em si leva **~26s** (18s esperando `db`+`web` ficarem saudáveis, 3s subindo o worker, 3s para o job ir do `web` ao `worker`, 1s de teardown). O que pesa é o build com `load`, ~1m45s contra ~1m10s de um build sem exportar a imagem — o preço de testar exatamente o artefato que vai ser publicado. O job inteiro fica em ~2m50s, e o CI completo em ~4min. As tags da imagem dependem do que disparou o build: