Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
167 changes: 161 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -95,15 +95,160 @@ 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 primeiros bugs de build deste projeto (CRLF em bin/*,
# plataforma ausente no Gemfile.lock) NÃO são exemplo disso — os
# 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 é 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.
- 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
Expand Down Expand Up @@ -160,12 +305,22 @@ 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.
#
# 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
cache-to: type=gha,mode=max
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<arquivo>` 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:**

Expand Down
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading