name: ci on: push: pull_request: 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-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