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

+278 -1
View File
@@ -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
+206
View File
@@ -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