CI com serviços reais e imagem Linux; deploy só manual

O CI rodava lint, typecheck, testes e build, e nada mais: os drivers de
Postgres e Redis nunca eram exercitados, e não havia build de imagem nem
pipeline de deploy.

- Job `services`: Postgres e Redis efêmeros. Roda a suíte de serviços, aplica
  as quatro migrations, confere idempotência, confere que `--dry-run` não
  altera nada e que o shim é pulado com ADAPTER_APPLY_IDENTITY_SHIM=0.
- Job `image`: build da imagem Linux e validação do artefato. Conteúdo de
  runtime, usuário não-root, ausência de .env, docs, testes e vitest na
  imagem, os oito scripts operacionais transformando e executando, entrypoint
  recusando subcomando desconhecido, fail-fast com código diferente de zero
  na web e no worker, liveness com cabeçalhos de segurança, compose completo
  até `ready`, heartbeat do worker, jornada assíncrona entre processos e
  desligamento gracioso exigindo `SIGTERM recebido` em menos de 25s.
- `.github/scripts/async-journey.sh` percorre web → Redis → worker → download
  dos três formatos, atravessando a fronteira de processo que a suíte de
  testes não cobre.
- Deploy com gatilho único `workflow_dispatch`: não há deploy automático em
  push, tag ou merge. Migration, deploy e liberação da feature flag são jobs
  separados, cada um com seu ambiente de aprovação manual.
- Nenhum segredo entra em comando de shell. Um input de workflow interpolado
  em `run:` é injeção, então `workspace_id` passa por variável de ambiente e
  é validado como UUID antes de qualquer escrita. O deploy usa heredoc citado
  e `permissions: contents: read` no topo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:39:56 -03:00
1 parent ce11a50091
commit a1ceef6c5f
3 files changed
+584 -1

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Jornada assíncrona ponta a ponta contra o compose, ENTRE PROCESSOS:
# a web (um contêiner) enfileira no Redis, o worker (outro contêiner) processa,
# e o download sai pela rota autenticada. É o que a suíte de testes não cobre:
# lá o tick roda no mesmo processo.
#
# Roda contra docker-compose.yml, que sobe em ADAPTER_ENV=development com
# ADAPTER_JOB_MODE=worker. Nenhuma credencial real é usada.
set -euo pipefail
BASE="${BASE:-http://127.0.0.1:3000}"
JAR="$(mktemp)"
ANA="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
ALPHA="11111111-1111-4111-8111-111111111111"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP" "$JAR"' EXIT
jq_get() { python3 -c "import json,sys;d=json.load(sys.stdin);print(eval(sys.argv[1],{},{'d':d}))" "$1"; }
echo "== 1. sessão local =="
curl -fsS -c "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"userId\":\"$ANA\",\"workspaceId\":\"$ALPHA\"}" \
"$BASE/api/dev/session" > /dev/null
echo "ok"
echo "== 2. modelo =="
printf 'Nome,Valor\n' > "$TMP/modelo.csv"
MODEL_ID=$(curl -fsS -b "$JAR" -X POST \
-F "name=Clientes CI" \
-F "requiredColumns=[\"Nome\",\"Valor\"]" \
-F "file=@$TMP/modelo.csv;type=text/csv" \
"$BASE/api/spreadsheet-adapter/models" | jq_get "d['model']['id']")
echo "modelo=$MODEL_ID"
echo "== 3. operação =="
JOB_ID=$(curl -fsS -b "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"modelId\":\"$MODEL_ID\",\"idempotencyKey\":\"ci-job\"}" \
"$BASE/api/spreadsheet-adapter/jobs" | jq_get "d['job']['id']")
echo "job=$JOB_ID"
echo "== 4. fonte =="
printf 'Nome,Valor\nAna,10\nBruno,20\n' > "$TMP/fonte.csv"
SOURCE_ID=$(curl -fsS -b "$JAR" -X POST \
-F "jobId=$JOB_ID" \
-F "file=@$TMP/fonte.csv;type=text/csv" \
"$BASE/api/spreadsheet-adapter/sources/validate" | jq_get "d['job']['sources'][0]['id']")
echo "fonte=$SOURCE_ID"
echo "== 5. mapeamento =="
curl -fsS -b "$JAR" -X PUT -H 'Content-Type: application/json' \
-d "{\"sourceId\":\"$SOURCE_ID\",\"mappings\":[{\"sourceColumn\":\"Nome\",\"modelColumn\":\"Nome\"},{\"sourceColumn\":\"Valor\",\"modelColumn\":\"Valor\"}]}" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/mappings" > /dev/null
echo "ok"
echo "== 6. processar: a web só enfileira =="
STATUS=$(curl -fsS -b "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"sourceId\":\"$SOURCE_ID\",\"idempotencyKey\":\"ci-proc\"}" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/process" | jq_get "d['job']['status']")
echo "status imediato=$STATUS"
if [ "$STATUS" != "queued" ] && [ "$STATUS" != "processing" ]; then
echo "ERRO: a web deveria enfileirar, não processar. status=$STATUS"
exit 1
fi
echo "== 7. aguardando o WORKER (outro contêiner) concluir =="
FINAL=""
for i in $(seq 1 60); do
FINAL=$(curl -fsS -b "$JAR" "$BASE/api/spreadsheet-adapter/jobs/$JOB_ID" | jq_get "d['job']['status']")
echo " tentativa $i: $FINAL"
[ "$FINAL" = "completed" ] && break
[ "$FINAL" = "failed" ] && break
sleep 2
done
if [ "$FINAL" != "completed" ]; then
echo "ERRO: o worker não concluiu. status=$FINAL"
docker compose logs worker | tail -40
exit 1
fi
echo "== 8. downloads =="
for fmt in xlsx csv pdf; do
curl -fsS -b "$JAR" -o "$TMP/out.$fmt" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/download?format=$fmt&sourceId=$SOURCE_ID"
size=$(wc -c < "$TMP/out.$fmt")
[ "$size" -gt 0 ] || { echo "ERRO: $fmt vazio"; exit 1; }
echo " $fmt: $size bytes"
done
head -c 2 "$TMP/out.xlsx" | grep -q 'PK' || { echo "ERRO: xlsx não é zip"; exit 1; }
head -c 4 "$TMP/out.pdf" | grep -q '%PDF' || { echo "ERRO: pdf inválido"; exit 1; }
grep -q 'Ana' "$TMP/out.csv" || { echo "ERRO: csv sem os dados"; exit 1; }
echo "== 9. o worker registrou o processamento =="
docker compose logs worker | tail -30 > "$TMP/worker.log"
grep -q "\[worker\] pronto" "$TMP/worker.log" || { echo "ERRO: worker não reportou start"; exit 1; }
echo "== 10. o modelo original não foi alterado =="
curl -fsS -b "$JAR" "$BASE/api/spreadsheet-adapter/models" | grep -q "Clientes CI"
echo ""
echo "JORNADA ASSÍNCRONA ENTRE PROCESSOS: OK"