Files
adaptador-planilhas/.github/workflows/ci.yml
T
camilafagundesandClaude Opus 5 c5790ab301
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled
runner-check / o runner responde (push) Has been cancelled
Protege o CI em repositório público e prepara a ativação do runner
O push para o Gitea criou o run, mas ele ficou em Waiting: a mensagem da
instância é "No matching online runner with label: ubuntu-latest". A falha é na
atribuição do job, não no enfileiramento — o gatilho funcionou e a branch está
contemplada. O endpoint /actions/tasks devolvia lista vazia porque lista tasks
aceitas por runner, não runs criados, e foi isso que confundiu a leitura.

Com o diagnóstico fechado, duas coisas importam antes de um runner existir.

Primeira: este repositório é público e o CI dispara em pull_request. Sem
guarda, um fork traria código de terceiro para rodar na máquina do runner, com
Docker à disposição. Os três jobs do ci.yml e o runner-check passam a exigir
que o PR venha da própria origem; push continua rodando normalmente. Também
entram `permissions: contents: read` e `concurrency` por ref, para não
acumular fila de runs esperando runner.

Segunda: `runner-check` é o primeiro workflow a rodar quando o runner subir.
Não faz checkout, não instala dependência, não usa segredo e não toca rede
externa. Só confirma a ligação e inventaria node, npm, docker, psql e python3
— o job `services` precisa de psql e a jornada assíncrona precisa de python3,
e é melhor descobrir isso num workflow de dez segundos.

A documentação registra a evidência (runs 666 em Waiting e 584 cancelado),
recomenda act_runner 0.6.1-dind-rootless com escopo de repositório, e explica
por que Docker-in-Docker em vez do socket do host: num repositório público,
montar o socket entregaria o Docker da máquina ao workflow. Fica registrado
também que os ambientes de aprovação manual do deploy são conceito do GitHub e
não têm equivalente no Gitea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:17:12 -03:00

318 lines
14 KiB
YAML

name: ci
on:
push:
pull_request:
# Mínimo necessário. Nenhum job deste workflow escreve no repositório.
permissions:
contents: read
# O repositório é público e o runner é uma máquina real. Um pull request de
# fork traria código de terceiro para rodar nele, com Docker à disposição.
# Enquanto não houver aprovação manual de workflow configurada na instância,
# só PR da própria origem executa. Push continua rodando normalmente.
# Evita fila de runs esperando runner: um push novo cancela o anterior do
# mesmo ref. O que já estava em execução não é interrompido no meio.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: lint, typecheck, testes e build
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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