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:
1 parent
ce11a50091
commit
a1ceef6c5f
3 files changed
+584
-1
No files matched your search
@@ -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"
|
||||||
+278
-1
@@ -6,18 +6,295 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
|
name: lint, typecheck, testes e build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
ADAPTER_ENV: development
|
||||||
ADAPTER_JOB_MODE: inline
|
ADAPTER_JOB_MODE: inline
|
||||||
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run typecheck
|
- run: npm run typecheck
|
||||||
- run: npm test
|
- run: npm test
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
|
services:
|
||||||
|
name: Postgres e Redis reais
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: adaptador
|
||||||
|
POSTGRES_PASSWORD: adaptador_ci
|
||||||
|
POSTGRES_DB: adaptador_ci
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U adaptador -d adaptador_ci"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
# Banco descartável do runner. A suíte recria o schema public.
|
||||||
|
ADAPTER_TEST_DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
|
||||||
|
ADAPTER_TEST_REDIS_URL: redis://localhost:6379
|
||||||
|
ADAPTER_ENV: development
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1"
|
||||||
|
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Migrations, shim, FORCE RLS e isolamento em Postgres real
|
||||||
|
run: npm run test:services
|
||||||
|
- name: As quatro migrations aplicam e o dry-run não altera nada
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
psql "$DATABASE_URL" -c 'drop schema if exists public cascade; create schema public;'
|
||||||
|
npm run migrate:check | tee /tmp/dry.log
|
||||||
|
grep -q "0001_platform_shim.sql" /tmp/dry.log
|
||||||
|
grep -q "0004_audit_append_only.sql" /tmp/dry.log
|
||||||
|
# dry-run não aplica: a tabela de controle não existe ainda
|
||||||
|
test "$(psql "$DATABASE_URL" -tAc "select count(*) from information_schema.tables where table_name='schema_migrations'")" = "0"
|
||||||
|
npm run migrate
|
||||||
|
test "$(psql "$DATABASE_URL" -tAc 'select count(*) from public.schema_migrations')" = "4"
|
||||||
|
# idempotente
|
||||||
|
npm run migrate | tee /tmp/again.log
|
||||||
|
grep -q "nada pendente" /tmp/again.log
|
||||||
|
- name: Shim explícito pode ser recusado sobre schema existente
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "0"
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
psql "$DATABASE_URL" -c 'drop schema if exists public cascade; create schema public;'
|
||||||
|
npm run migrate:check | tee /tmp/noshim.log
|
||||||
|
if grep -q "0001_platform_shim.sql" /tmp/noshim.log; then
|
||||||
|
echo "ERRO: o shim apareceria como pendente com ADAPTER_APPLY_IDENTITY_SHIM=0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q "NÃO será aplicado" /tmp/noshim.log
|
||||||
|
|
||||||
|
image:
|
||||||
|
name: imagem Docker Linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ADAPTER_IMAGE: adaptador-planilhas:ci
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build da imagem de runtime
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: adaptador-planilhas:ci
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conteúdo da imagem
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Arquivos de runtime presentes
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker run --rm adaptador-planilhas:ci exec ls server.js .next/static migrations scripts src tsconfig.json
|
||||||
|
docker run --rm adaptador-planilhas:ci exec node -e "require.resolve('tsx')"
|
||||||
|
docker run --rm adaptador-planilhas:ci exec sh -c 'ls migrations/*.sql | wc -l | grep -qx 4'
|
||||||
|
|
||||||
|
- name: Imagem não roda como root
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
test "$(docker run --rm adaptador-planilhas:ci exec id -u)" = "1001"
|
||||||
|
|
||||||
|
- name: Imagem não carrega segredo, .env, docs nem testes
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
for proibido in .env .env.local .env.staging tests docs .git node_modules/vitest; do
|
||||||
|
if docker run --rm adaptador-planilhas:ci exec sh -c "test -e '$proibido'"; then
|
||||||
|
echo "ERRO: $proibido está na imagem"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "ok: nada indevido na imagem"
|
||||||
|
|
||||||
|
- name: Scripts operacionais transformam e executam sem devDependencies
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Cada script precisa TRANSFORMAR e EXECUTAR com o node_modules de
|
||||||
|
# produção. `node --check` não serve: não parseia TypeScript. Então
|
||||||
|
# rodamos de verdade e exigimos a saída de recusa do nosso próprio
|
||||||
|
# código — se a transformação falhasse, a mensagem seria outra.
|
||||||
|
# Isto é a prova do defeito do top-level await em CJS, que impedia
|
||||||
|
# `node --import tsx`.
|
||||||
|
|
||||||
|
verifica() {
|
||||||
|
nome="$1"; esperado="$2"; shift 2
|
||||||
|
saida=$(docker run --rm "$@" adaptador-planilhas:ci 2>&1 || true)
|
||||||
|
if printf '%s' "$saida" | grep -q "$esperado"; then
|
||||||
|
echo " ok: $nome"
|
||||||
|
else
|
||||||
|
echo "ERRO: $nome não produziu a saída esperada ($esperado)"
|
||||||
|
echo "$saida"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scripts com bootstrap: recusam a configuração de staging ausente,
|
||||||
|
# o que só acontece depois de transformar e executar nosso código.
|
||||||
|
verifica "migrate" "Configuração de staging recusada" -e ADAPTER_ENV=staging migrate
|
||||||
|
verifica "worker" "Configuração de staging recusada" -e ADAPTER_ENV=staging worker
|
||||||
|
verifica "cleanup" "Configuração de staging recusada" -e ADAPTER_ENV=staging cleanup
|
||||||
|
verifica "seed-staging" "Configuração de staging recusada" -e ADAPTER_ENV=staging seed-staging
|
||||||
|
verifica "flag" "uso: npm run flag" -e ADAPTER_ENV=staging flag
|
||||||
|
# Scripts sem bootstrap: recusam pela ausência das próprias variáveis.
|
||||||
|
verifica "check-bucket" "Defina NEXT_PUBLIC_SUPABASE_URL" check-bucket
|
||||||
|
verifica "smoke" "Defina ADAPTER_SMOKE_URL" smoke
|
||||||
|
# E o caminho feliz do verificador de configuração.
|
||||||
|
docker run --rm -e ADAPTER_ENV=development adaptador-planilhas:ci verify-config | grep -q "configuração aceita"
|
||||||
|
echo "ok: os 8 scripts operacionais transformam e executam"
|
||||||
|
|
||||||
|
- name: Entrypoint responde aos subcomandos e recusa o desconhecido
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker run --rm adaptador-planilhas:ci --help | grep -q "worker-health"
|
||||||
|
if docker run --rm adaptador-planilhas:ci comando-que-nao-existe; then
|
||||||
|
echo "ERRO: subcomando desconhecido foi aceito"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: subcomando desconhecido recusado"
|
||||||
|
# O worker precisa rodar no MESMO processo: o CLI do tsx cria filho e
|
||||||
|
# o SIGTERM do docker stop pararia nele.
|
||||||
|
docker run --rm adaptador-planilhas:ci exec grep -q 'import=tsx' docker/entrypoint.sh
|
||||||
|
if docker run --rm adaptador-planilhas:ci exec grep -q 'tsx/dist/cli' docker/entrypoint.sh; then
|
||||||
|
echo "ERRO: o entrypoint usa o CLI do tsx, que cria processo filho"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: entrypoint usa node --import=tsx"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fail-fast de configuração
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Configuração ausente encerra com código diferente de zero
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# web
|
||||||
|
docker run --name ff-web -e ADAPTER_ENV=staging adaptador-planilhas:ci web > /tmp/ff-web.log 2>&1 && {
|
||||||
|
echo "ERRO: a web subiu sem configuração"; exit 1; }
|
||||||
|
code=$(docker inspect ff-web --format '{{.State.ExitCode}}')
|
||||||
|
echo "web exit=$code"; test "$code" != "0"
|
||||||
|
grep -q "Configuração de staging recusada" /tmp/ff-web.log
|
||||||
|
grep -q "DATABASE_URL" /tmp/ff-web.log
|
||||||
|
docker rm -f ff-web
|
||||||
|
# worker
|
||||||
|
docker run --name ff-worker -e ADAPTER_ENV=staging adaptador-planilhas:ci worker > /tmp/ff-worker.log 2>&1 && {
|
||||||
|
echo "ERRO: o worker subiu sem configuração"; exit 1; }
|
||||||
|
code=$(docker inspect ff-worker --format '{{.State.ExitCode}}')
|
||||||
|
echo "worker exit=$code"; test "$code" != "0"
|
||||||
|
docker rm -f ff-worker
|
||||||
|
# dev auth em staging precisa ser recusado
|
||||||
|
docker run --rm -e ADAPTER_ENV=staging -e ADAPTER_DEV_AUTH=1 adaptador-planilhas:ci verify-config 2>&1 \
|
||||||
|
| grep -q "ADAPTER_DEV_AUTH" || { echo "ERRO: dev auth aceito em staging"; exit 1; }
|
||||||
|
echo "ok: fail-fast confirmado"
|
||||||
|
|
||||||
|
- name: Liveness responde e não mente sobre dependência
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker run -d --name ci-web -e ADAPTER_ENV=development -p 3000:3000 adaptador-planilhas:ci
|
||||||
|
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:3000/api/health/live && break || sleep 2; done
|
||||||
|
curl -fsS http://127.0.0.1:3000/api/health/live | grep -q '"status":"live"'
|
||||||
|
curl -fsS http://127.0.0.1:3000/api/health/live | grep -q '"environment":"development"'
|
||||||
|
# Cabeçalhos de segurança na resposta real
|
||||||
|
curl -fsSI http://127.0.0.1:3000/api/health/live | grep -qi 'content-security-policy'
|
||||||
|
curl -fsSI http://127.0.0.1:3000/api/health/live | grep -qi 'strict-transport-security'
|
||||||
|
# Dev auth não existe quando a flag não foi ligada
|
||||||
|
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"userId":"00000000-0000-4000-8000-000000000000","workspaceId":"00000000-0000-4000-8000-000000000000"}' \
|
||||||
|
http://127.0.0.1:3000/api/dev/session)" = "404"
|
||||||
|
docker rm -f ci-web
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Topologia completa: web + worker + Postgres + Redis
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Compose sobe, migra e fica ready
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker compose up -d
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS http://127.0.0.1:3000/api/health/live >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
docker compose exec -T web migrate
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
if curl -fsS http://127.0.0.1:3000/api/health/ready | grep -q '"status":"ready"'; then break; fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:3000/api/health/ready | tee /tmp/ready.json
|
||||||
|
grep -q '"status":"ready"' /tmp/ready.json
|
||||||
|
grep -q '"redis":"ok"' /tmp/ready.json
|
||||||
|
grep -q '"database":"ok"' /tmp/ready.json
|
||||||
|
grep -q '"migrations":"ok"' /tmp/ready.json
|
||||||
|
# Nenhuma connection string na resposta
|
||||||
|
if grep -q 'postgres://' /tmp/ready.json; then echo "ERRO: vazou connection string"; exit 1; fi
|
||||||
|
|
||||||
|
- name: Worker vivo, com heartbeat
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker compose logs worker | tee /tmp/worker.log
|
||||||
|
grep -q "\[worker\] pronto" /tmp/worker.log
|
||||||
|
docker compose exec -T worker /app/docker/entrypoint.sh worker-health
|
||||||
|
|
||||||
|
- name: Jornada assíncrona ponta a ponta entre processos
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Sessão local (o compose local roda em ADAPTER_ENV=development),
|
||||||
|
# upload de modelo e de fonte, mapeamento, processamento pelo WORKER
|
||||||
|
# em outro contêiner, e download dos três formatos.
|
||||||
|
bash .github/scripts/async-journey.sh
|
||||||
|
|
||||||
|
- name: Desligamento gracioso do worker
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
start=$(date +%s)
|
||||||
|
docker compose stop -t 30 worker
|
||||||
|
end=$(date +%s)
|
||||||
|
code=$(docker inspect "$(docker compose ps -aq worker)" --format '{{.State.ExitCode}}')
|
||||||
|
docker compose logs worker | tail -20 | tee /tmp/stop.log
|
||||||
|
echo "parada em $((end - start))s, exit=$code"
|
||||||
|
# SIGTERM precisa CHEGAR ao processo: o entrypoint usa `node --import tsx`
|
||||||
|
# justamente porque o CLI do tsx cria processo filho e o sinal pararia nele.
|
||||||
|
grep -q "SIGTERM recebido" /tmp/stop.log
|
||||||
|
grep -q "\[worker\] encerrado" /tmp/stop.log
|
||||||
|
test "$code" = "0"
|
||||||
|
# Se tivesse levado SIGKILL, a parada bateria no limite de 30s.
|
||||||
|
test $((end - start)) -lt 25
|
||||||
|
|
||||||
|
- name: Limpeza
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
ADAPTER_IMAGE: adaptador-planilhas:ci
|
||||||
|
run: docker compose down -v || true
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
name: deploy homologacao
|
||||||
|
|
||||||
|
# Cada passo é um job separado com aprovação própria. Nada roda sozinho:
|
||||||
|
# migration, deploy e liberação da feature flag são decisões distintas, na
|
||||||
|
# ordem do runbook docs/spreadsheet-adapter/20-homologacao-runbook.md.
|
||||||
|
#
|
||||||
|
# O único gatilho é workflow_dispatch: não existe deploy automático em nenhum
|
||||||
|
# push, tag ou merge.
|
||||||
|
#
|
||||||
|
# Ambientes do GitHub a criar com "required reviewers":
|
||||||
|
# hml-migracao → aplica migrations no banco de homologação
|
||||||
|
# hml-deploy → sobe web e worker em hml-ada.elopro.app
|
||||||
|
# hml-flag → liga a feature flag do workspace de homologação
|
||||||
|
#
|
||||||
|
# Segredos esperados nos ambientes (nenhum compartilhado com produção):
|
||||||
|
# HML_DATABASE_URL (hml-migracao, hml-flag)
|
||||||
|
# HML_SSH_HOST, HML_SSH_USER, HML_SSH_KEY, HML_DEPLOY_PATH (hml-deploy)
|
||||||
|
# HML_SMOKE_EMAIL, HML_SMOKE_PASSWORD (hml-deploy, opcionais)
|
||||||
|
# Variáveis:
|
||||||
|
# HML_APPLY_IDENTITY_SHIM, HML_SMOKE_WORKSPACE
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
workspace_id:
|
||||||
|
description: "workspace de homologação para a feature flag (job flag)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-homologacao
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
# Mínimo necessário. O job de build eleva só o que precisa.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: 1. build e publicação da imagem
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
outputs:
|
||||||
|
image: ${{ steps.meta.outputs.image }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- id: meta
|
||||||
|
run: |
|
||||||
|
image="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::12}"
|
||||||
|
echo "image=${image,,}" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.image }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
# Release do Sentry igual ao SHA: o stack trace do servidor fica legível.
|
||||||
|
build-args: |
|
||||||
|
SENTRY_RELEASE=${{ github.sha }}
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
name: 2. migrations (aprovação manual)
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: hml-migracao
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: O que vai rodar, antes de rodar
|
||||||
|
env:
|
||||||
|
ADAPTER_ENV: staging
|
||||||
|
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
|
||||||
|
run: npm run migrate:check
|
||||||
|
- name: Aplicar migrations
|
||||||
|
env:
|
||||||
|
ADAPTER_ENV: staging
|
||||||
|
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
|
||||||
|
run: npm run migrate
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: 3. deploy web e worker (aprovação manual)
|
||||||
|
needs: [build, migrate]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: hml-deploy
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Confere que os segredos do destino existem
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.HML_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.HML_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.HML_SSH_KEY }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.HML_DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
missing=""
|
||||||
|
[ -z "$SSH_HOST" ] && missing="$missing HML_SSH_HOST"
|
||||||
|
[ -z "$SSH_USER" ] && missing="$missing HML_SSH_USER"
|
||||||
|
[ -z "$SSH_KEY" ] && missing="$missing HML_SSH_KEY"
|
||||||
|
[ -z "$DEPLOY_PATH" ] && missing="$missing HML_DEPLOY_PATH"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "Segredos ausentes:$missing"
|
||||||
|
echo "Configure o ambiente hml-deploy antes de usar este workflow."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: segredos presentes"
|
||||||
|
|
||||||
|
- name: Sobe apenas os serviços deste módulo
|
||||||
|
# Segredos entram por variável de ambiente, nunca interpolados no
|
||||||
|
# comando: um valor com metacaractere viraria injeção de shell. O
|
||||||
|
# heredoc é citado, então o script chega literal ao destino.
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ needs.build.outputs.image }}
|
||||||
|
SSH_HOST: ${{ secrets.HML_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.HML_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.HML_SSH_KEY }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.HML_DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s' "$SSH_KEY" > ~/.ssh/id_deploy
|
||||||
|
echo >> ~/.ssh/id_deploy
|
||||||
|
chmod 600 ~/.ssh/id_deploy
|
||||||
|
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# O --project-name embutido no compose isola este stack dos outros
|
||||||
|
# aplicativos do servidor, e --no-deps não reinicia nada além de web
|
||||||
|
# e worker. Nenhum `down`, nenhum `prune`, nenhum serviço de terceiro.
|
||||||
|
ssh -i ~/.ssh/id_deploy "$SSH_USER@$SSH_HOST" \
|
||||||
|
ADAPTER_IMAGE="$IMAGE" DEPLOY_PATH="$DEPLOY_PATH" bash -s <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
export ADAPTER_IMAGE
|
||||||
|
C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
|
||||||
|
$C pull web worker
|
||||||
|
$C up -d redis
|
||||||
|
$C up -d --no-deps web worker
|
||||||
|
$C ps
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
- name: Smoke de homologação
|
||||||
|
env:
|
||||||
|
ADAPTER_SMOKE_URL: https://hml-ada.elopro.app
|
||||||
|
# Fase 2 só roda se a operação cadastrar estes segredos, com uma conta
|
||||||
|
# criada no painel do Supabase de homologação. Sem eles o script
|
||||||
|
# reporta PENDENTE, nunca aprovado.
|
||||||
|
ADAPTER_SMOKE_EMAIL: ${{ secrets.HML_SMOKE_EMAIL }}
|
||||||
|
ADAPTER_SMOKE_PASSWORD: ${{ secrets.HML_SMOKE_PASSWORD }}
|
||||||
|
ADAPTER_SMOKE_WORKSPACE: ${{ vars.HML_SMOKE_WORKSPACE }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
npm ci
|
||||||
|
npm run smoke:staging
|
||||||
|
|
||||||
|
flag:
|
||||||
|
name: 4. liberar a feature flag (aprovação manual)
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: hml-flag
|
||||||
|
if: inputs.workspace_id != ''
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Ligar o módulo somente no workspace informado
|
||||||
|
env:
|
||||||
|
ADAPTER_ENV: staging
|
||||||
|
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
|
||||||
|
# Pelo ambiente, não interpolado no comando: um input é texto de quem
|
||||||
|
# dispara o workflow, e interpolá-lo em shell é injeção.
|
||||||
|
WORKSPACE_ID: ${{ inputs.workspace_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
UUID='^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
|
||||||
|
if ! printf '%s' "$WORKSPACE_ID" | grep -qiE "$UUID"; then
|
||||||
|
echo "workspace_id não é um UUID: recusado antes de qualquer escrita"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm run flag -- "$WORKSPACE_ID" on
|
||||||
Reference in new issue
Block a user