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>
318 lines
14 KiB
YAML
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
|