From a1ceef6c5f7d460a10a040f5e7041e5ec0237d6e Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:39:56 -0300 Subject: [PATCH] =?UTF-8?q?CI=20com=20servi=C3=A7os=20reais=20e=20imagem?= =?UTF-8?q?=20Linux;=20deploy=20s=C3=B3=20manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/scripts/async-journey.sh | 100 ++++++++++ .github/workflows/ci.yml | 279 ++++++++++++++++++++++++++- .github/workflows/deploy-staging.yml | 206 ++++++++++++++++++++ 3 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/async-journey.sh create mode 100644 .github/workflows/deploy-staging.yml diff --git a/.github/scripts/async-journey.sh b/.github/scripts/async-journey.sh new file mode 100644 index 0000000..5a92453 --- /dev/null +++ b/.github/scripts/async-journey.sh @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6909506..2e8ddfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..f5ae89e --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -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