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:
|
||||
verify:
|
||||
name: lint, typecheck, testes e build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ADAPTER_ENV: development
|
||||
ADAPTER_JOB_MODE: inline
|
||||
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- 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