feat(OPS88): verify do CI/deploy com um Postgres e reset determinístico, um build por run (Closes #834)

This commit is contained in:
Francisco Solla 2026-08-24 14:06:59 -03:00
parent 9c5cd7830b
commit 9fb77e2e25
12 changed files with 276 additions and 64 deletions

View file

@ -35,12 +35,13 @@
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
# as the RCE guard on the post-merge workflows.
#
# Postgres services (OPS71 — GitHub hosted): jobs run directly on the runner
# Postgres service (OPS71 — GitHub hosted): jobs run directly on the runner
# VM (NOT inside the job-container network of the Forgejo era), so services
# are reached at localhost on PUBLISHED host ports. Each hosted run is its own
# VM, so fixed ports never collide across runs. Two isolated services — int
# on 5432 and build+e2e on 5433 — keep e2e from inheriting int fixture
# residue.
# VM, so fixed ports never collide across runs. ONE service (OPS88): a
# deterministic reset (`pnpm db:reset` — drop schema → migrate → seed
# minimal) between the integration phase and the build/e2e phase keeps e2e
# from inheriting int fixture residue, instead of a second container.
name: CI (PR)
on:
@ -77,19 +78,6 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
@ -195,21 +183,19 @@ jobs:
VITEST_MAX_WORKERS: 4
run: node scripts/vitest-changed-or-full.mjs --suite int --base origin/${{ github.base_ref }}
- name: Prepare database URL (build/e2e service)
- name: Reset database (drop → migrate → seed)
# OPS88: one Postgres per run — the build/e2e phase starts from the
# same clean baseline (drop schema → migrate → seed:minimal) instead
# of a second container. Runs whenever a build phase follows
# (selected/curated e2e always builds `.next-e2e` — OPS86).
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
run: pnpm migrate
- name: Seed minimal (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
run: pnpm db:seed:minimal
run: pnpm db:reset
- name: Build (against the local service database)
if: steps.scope.outputs.build_mode != 'none'
# Only when it is the single build of the run: selected/curated e2e
# runs serve the `.next-e2e` build (below), so the default build is
# skipped there (OPS88 — one build per run when build+e2e run together).
if: steps.scope.outputs.build_mode != 'none' && steps.scope.outputs.e2e_mode != 'selected' && steps.scope.outputs.e2e_mode != 'curated'
run: pnpm build
- name: Cache Playwright browsers

View file

@ -43,8 +43,10 @@ jobs:
services:
# OPS71 — GitHub hosted: jobs run on the runner VM, so services are
# reached at localhost on PUBLISHED host ports (fixed — each run is its
# own VM). Two isolated services — int on 5432, build+e2e on 5433 —
# keep e2e from inheriting int fixture residue.
# own VM). ONE service (OPS88): a deterministic reset (`pnpm db:reset`
# — drop schema → migrate → seed minimal) between the integration
# phase and the e2e build keeps e2e from inheriting int fixture
# residue, instead of a second container.
postgres-int:
image: postgres:17-alpine
env:
@ -58,19 +60,6 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
@ -117,17 +106,12 @@ jobs:
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Prepare database URL (build/e2e service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
run: pnpm migrate
- name: Seed minimal (build/e2e database)
run: pnpm db:seed:minimal
- name: Build (against the service database)
run: pnpm build
- name: Reset database (single service)
# OPS88: one Postgres per run — the e2e build starts from the same
# clean baseline (drop schema → migrate → seed:minimal) instead of a
# second container. The full suite always runs here, so only the
# `.next-e2e` build (below) exists — the default build is gone.
run: pnpm db:reset
- name: Cache Playwright browsers
uses: actions/cache@v4

View file

@ -5,9 +5,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**OPS89 — variantes do provider Vercel AI Gateway movidas do repo para a config global do opencode (2026-08-24):** fecha o ciclo aberto pela OPS78/OPS82: as variantes low/high/max dos dois DeepSeek V4 Flash (`deepseek-v4-flash` e `-0731`) saíram do `opencode.json` do repo e voltam a ser preferência de máquina no global `~/.config/opencode/opencode.jsonc` (aplicação manual pelo dono — nada commitado; auth continua em `~/.local/share/opencode/auth.json`). O repo fica mínimo: só `$schema` + `mcp.penpot` — o mesmo princípio "edit the owner, don't twin" da OPS82, agora para o próprio `provider.vercel` (o anti-goal original da OPS78 de não commitar config de opencode no repo prevalece). Config-only + prosa: `-32` linhas de config; docblock de `OPENCODE_PRESET_MODEL` (`scripts/lib/worktree.mjs`) e prosa de `worktree-next-issue/SKILL.md` passam a apontar para a config global; a constante (model ID) e os pins de `worktree.unit.spec.ts` ficam intactos — o launch de `pnpm worktree next` não muda. No mesmo diff: "Forgejo" → "GitHub" na linha reescrita da skill (rastro stale pós-cutover); a varredura ampla do resíduo virou a OPS91 (#841). Verificação: `pnpm gate:fast` verde (256 arquivos/2443 testes), JSON estrito validado, grep sem ref viva a `provider.vercel` fora do docblock atualizado; aceite final do TUI condicionado ao global aplicado pelo dono (`opencode debug config`, `--variant max`, Ctrl+T).
**OPS90 — skills e docs: codificar a pirâmide de testes unit→int→e2e-com-benefício (2026-08-24):** meta-chore de guias de agente, sem código de produto/schema/CI. A ordem de preferência unit primeiro → int para fronteiras Payload/DB → e2e só com benefício real por fluxo ficou explícita nos donos certos: âncora canônica nova "This repo: the three layers" na skill `test-driven-development` (mapeia a pirâmide às camadas `tests/unit|int|e2e` com fixtures e aponta a política OPS72 por referência); linha das 3 camadas do `codebase-map.mdc` reescrita com a ordem; parágrafo "Level preference (pyramid)" em `docs/TESTING.md`; preferência de nível enunciada no §Executar da `execution-pipeline.md` (compartilhada por `work-issue`/`agent-work-issue`) e como bullet no Passo 4 do `work-issue`; revisão ganha o critério de nível em `code-review-and-quality` (Step 2 + checklist) e `improve-code-quality` (exit checklist); `browser-testing-with-devtools` rotula verificação em browser como caso legítimo de "e2e com benefício real". Política OPS72 intacta byte-a-byte (gate local discricionário, CI `selected`, full só no verify do deploy); sem % de cobertura, sem proibição de e2e, sem guard novo; `ci-cd-and-automation`/`engineering-audit`/`AGENTS*` intocados (sem lacuna real). Débitos: 6 achados de simplify absorvidos no diff; contradição stale do tracker Forgejo já coberta pela Issue #841.
**OPS88 — verify do CI (PR e deploy): um Postgres com reset determinístico, um build, migrate/seed sem repetição (2026-08-24):** o `checks` do PR e o `verify` do deploy reconstruíam o mesmo estado até 4× por run (dois containers Postgres `postgres-int`/`postgres-build`, dois migrates explícitos, dois seeds, dois builds — cada build re-rodando migrate embutido; o build default `.next` nem era servido por nada). Agora: **um Postgres por run** com reset determinístico entre fases — script novo `pnpm db:reset` (`scripts/db-reset.mjs`: `DROP SCHEMA public CASCADE` → `CREATE SCHEMA public` → `pnpm migrate` → `pnpm db:seed:minimal`, guard fail-closed por construção: só host local E nome `teqo(_<wt>)?_test`, sem escape `ALLOW_REMOTE_DB` — um DROP não pode herdar o override remoto dos seeds) — e **um build por run** quando build+e2e rodam juntos (o e2e continua servindo o `.next-e2e`; o build default só roda quando é o único build da run; no `verify` do deploy ele saiu de vez, pois o e2e full sempre roda lá). Contrato do nome `_test` extraído para `TEST_DATABASE_NAME_RE` em `scripts/lib/cli.mjs` (fonte única com `assertTestDatabase.ts`); `scripts/db-reset.mjs` entra no `HIGH_RISK_EXACT` (harness de teste) e o mirror local `gate:ci` ganha o reset antes do build (paridade CI↔local); `deploy` no homeserver intocado (migrate-before-build OPS66 preservado — o migrate embutido do build vira no-op aceito). Contabilização honesta: ciclos de estado 4→2, Postgres 2→1, builds 2→1, migrates executados 4→3 (residual = no-op embutido), seeds 2→2. Verificação: guards fail-closed (3 recusas), reset sobre resíduo real (tabela junk somada → baseline 435 municípios + seed minimal de volta), idempotência (re-execução), suite int **full** verde pós-reset (830 tests); `pnpm lint`/`tsc`/`format:check`/`knip`/`check:cycles`/`test:unit` (2445) verdes.
**OPS83 — desbloquear deploy: estabilizar os e2e do verify sem reduzir cobertura; AgendaFeed volta a rodar em prod (2026-08-24):** o deploy manual (`deploy.yml`) estava bloqueado pelo job `verify` (run #15: 1 failed + 7 flaky + 2 skipped + 1 did-not-run). Causas estudadas e corrigidas por reescrita de specs (settle/poll/hermeticidade/escopo de locator) — **nenhum** `test.flaky`, nenhuma remoção de cobertura, nenhuma mudança de workers/retries, zero mudança de app/contrato/acesso: (1) kill switch da home — o poll de server-HTML convergia enquanto a seção renderizada ainda mostrava o link do Instagram; passou a pollar o **DOM rendered** escopado a `[data-home-section="contents"]` (`expectSectionCardCount`, usado também no sibling de fallback IG); (2) section-hidden — o revalidate `tag=posts` era engolido por `.catch`; agora o POST é **assertado** e a convergência ganha orçamento explícito (não bump cego); (3) SavedFilters strict-mode — o link 'Municípios' agora é escopado ao container `[data-slot="sidebar"]` (2º match = bottom nav, #669 absorvida); (4) People — gate `S:` + settle do stream antes de asserir rows (testes 15, pós-search e C118); (5) Municípios:642 — poll DOM da liderança específica em vez de `.first()` no stream; (6) Municípios:386 B176 — `settleMunicipalityStream` entre remove-chip e o próximo clique de opção; (7) Newsletter:222 — `toHaveURL(/#novidades/)` sem `$` (o `?e2e=scroll-…` é legítimo); (8) DemandVisibility:62 — `.goto` com cache-buster para a asserção de 404 do peer (a cache RSC servia a demanda acessível após a troca de cookie). **AgendaFeed (Decision B):** os 2 `test.skip(isProdMode)` foram **removidos** — o build e2e agora inlina `NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test` (`.test` TLD, passa o fail-closed `requireProductionDNSOrigin`), o diálogo gera o link canônico e os testes asserem o **path** e buscam via `campaign.baseURL + path`; confirmado que share usa `window.location.origin`, webauthn é desacoplado via request-host e webhooks usam `campaign.baseURL` — nenhum outro e2e depende do origin. Escopo config: `playwright.config.ts` (default do webServer env em prod), `deploy.yml` e `ci-pr.yml` (env do build-for-e2e + do passo de testes). Verificação: specs afetadas verdes em dev mode (2×) e AgendaFeed verde em `E2E_PROD=1`; `pnpm lint`/`tsc`/`format:check`/`knip`/`check:cycles`/`test:unit` (2443) verdes; PR em main com auto-merge.

View file

@ -0,0 +1 @@
**OPS88 — verify do CI (PR e deploy): um Postgres com reset determinístico, um build, migrate/seed sem repetição (2026-08-24):** o `checks` do PR e o `verify` do deploy reconstruíam o mesmo estado até 4× por run (dois containers Postgres `postgres-int`/`postgres-build`, dois migrates explícitos, dois seeds, dois builds — cada build re-rodando migrate embutido; o build default `.next` nem era servido por nada). Agora: **um Postgres por run** com reset determinístico entre fases — script novo `pnpm db:reset` (`scripts/db-reset.mjs`: `DROP SCHEMA public CASCADE` → `CREATE SCHEMA public` → `pnpm migrate` → `pnpm db:seed:minimal`, guard fail-closed por construção: só host local E nome `teqo(_<wt>)?_test`, sem escape `ALLOW_REMOTE_DB` — um DROP não pode herdar o override remoto dos seeds) — e **um build por run** quando build+e2e rodam juntos (o e2e continua servindo o `.next-e2e`; o build default só roda quando é o único build da run; no `verify` do deploy ele saiu de vez, pois o e2e full sempre roda lá). Contrato do nome `_test` extraído para `TEST_DATABASE_NAME_RE` em `scripts/lib/cli.mjs` (fonte única com `assertTestDatabase.ts`); `scripts/db-reset.mjs` entra no `HIGH_RISK_EXACT` (harness de teste) e o mirror local `gate:ci` ganha o reset antes do build (paridade CI↔local); `deploy` no homeserver intocado (migrate-before-build OPS66 preservado — o migrate embutido do build vira no-op aceito). Contabilização honesta: ciclos de estado 4→2, Postgres 2→1, builds 2→1, migrates executados 4→3 (residual = no-op embutido), seeds 2→2. Verificação: guards fail-closed (3 recusas), reset sobre resíduo real (tabela junk somada → baseline 435 municípios + seed minimal de volta), idempotência (re-execução), suite int **full** verde pós-reset (830 tests); `pnpm lint`/`tsc`/`format:check`/`knip`/`check:cycles`/`test:unit` (2445) verdes.

View file

@ -0,0 +1,131 @@
# Impl: Verify do CI (PR e deploy): um Postgres com reset determinístico, um build, migrate/seed sem repetição
Status: rascunho
Atualizado em: 2026-08-24
Issue: #834
Intenção: docs/plans/ops88-ci-verify-setup-unico.md
Appetite restante: herdado (~0,5–1 dia eng; um outcome verificável)
## Leitura da intenção
- **Outcome:** numa run de PR com build+e2e, o app compila uma única vez e integração, build e e2e rodam no mesmo Postgres, com reset determinístico entre as fases; o verify do deploy usa o mesmo setup único e continua gateando a publicação; migrates/seeds/builds repetidos caem (4 ciclos de reconstrução de estado → ~2), sem perda de garantia e sem re-run com ganho revertido.
- **O que NÃO negociar:** não virar "rodar menos testes" (PR continua `selected`, deploy continua `full`); não paralelizar o job único (fail-fast estrutural); o reset entre fases É a proteção do e2e contra resíduo do int — não removível; e2e sempre contra build de produção (`.next-e2e`), nunca dev; migrate-before-build embutido no `pnpm build` (contrato OPS66/deploy-homeserver) intacto; deploy homeserver intocado; guards de banco local/`_test`.
- **O que reavaliar:** a "Direção no codebase" (workflows, `tests/helpers`, `playwright.config.ts`) foi confirmada na exploração, com uma ressalva: o build default hoje não é servido por nada no CI (o e2e serve `.next-e2e` e o prod compila no homeserver) — isso valida a intenção B (colapsar só quando build+e2e rodam juntos) e o corte do build default no verify do deploy, onde e2e sempre roda. O "resíduo do int é finito e conhecido" também se confirmou: fixtures (`campaignFixtures.ts`), pins de contagem (435 municípios, `municipalityCatalog.length + 1`) e claims — nada que um drop-schema+migrate+seed não limpe.
## Abordagem recomendada
```mermaid
flowchart LR
A["1 Postgres service (5432)"] --> B["migrate + seed minimal (como hoje)"]
B --> C["test:int (full/changed)"]
C --> D{"build ou e2e na run?<br/>build_mode != 'none' || e2e selected"}
D -- não --> F["fim (PR docs-only)"]
D -- sim --> E["db:reset<br/>(drop schema → migrate → seed minimal)"]
E --> G{"e2e selected?"}
G -- sim --> H["build .next-e2e (único build)"] --> I["e2e (specs selecionadas)"]
G -- não --> J["build default .next (único build)"]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — um Postgres por run + script de reset dedicado entre as fases. O reset (drop schema → migrate → seed minimal) é exatamente o que o e2e precisa para não herdar resíduo do int; o segundo container era o workaround sem reset. O script é testável localmente, reusável no mirror `gate-ci` e protege o único ponto novo da arquitetura.
**Rejeitadas:**
- **B — manter dois Postgres** (status quo): não atende o aceite "um Postgres por run" e o ganho de tempo; o resíduo que o 2º container escondia continua sem solução determinística.
- **C — reset inline no YAML (psql + pnpm)**: o runner hosted não tem `psql` (o Postgres é service container, não ferramenta do runner); lógica de banco no YAML não é testável nem reusável no mirror local; duplicaria guards.
### Decisões de engenharia (caras de reverter)
#### D1 — Onde vive o reset determinístico
- **Opções:**
- **A) Script novo `scripts/db-reset.mjs`** + `pnpm db:reset`: `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` via pool `pg` (já é devDependency — sem dependência de `psql` no runner), depois `execFileSync` de `pnpm migrate` e `pnpm db:seed:minimal` (mesmo padrão do `gate-ci.mjs`). Guard: `assertLocalDatabase` (host local) **e** o contrato de nome `^teqo(_[a-z0-9]+)?_test$` (o mesmo de `tests/helpers/assertTestDatabase.ts:13`) — um script que DROP SCHEMA não pode aceitar `teqo` (dev local tem dados de trabalho) nem nada remoto.
- **B) Passo YAML inline** com `docker run postgres:17-alpine psql` + `pnpm migrate` + `pnpm db:seed:minimal`.
- **C) Reusar `scripts/db-pull.mjs`** (ele já faz o drop em `db-pull.mjs:119`).
- **Recomendação:** A — determinístico, testável localmente, reusável no `gate-ci.mjs` (paridade CI↔local), espelha o shape do `seed-minimal.mjs` (guard + `loadCliEnv`). **Nome:** script `pnpm db:reset` novo na família `db:*` do `package.json` — **não** uma flag em `db:seed:minimal` (o seed é idempotente e compartilhado por worktree/gate; não acoplar DROP a ele). **Guard obrigatório por construção:** host local (herdado de `assertLocalDatabase`) + sufixo `_test` — fail-closed antes do DDL.
- **Rejeitadas:** B (sem `psql` no runner hosted; lógica no YAML = intocável fora do CI; guard duplicado e divergente); C (`db-pull` faz pg_dump de prod com filtro de PII, restaura via container local — outro contrato; acoplar reset a ele o tornaria inexecutável no CI).
- **Detalhe do contrato compartilhado:** exportar o regex de `_test` de `tests/helpers/assertTestDatabase.ts:13` para `scripts/lib/cli.mjs` (fonte única — `assertTestDatabase.ts` já importa `defaultGatewayHost` de lá), consumido por ambos. Alternativa aceitável (menor churn): duplicar o regex no script com comentário apontando o contrato — decidir no impl, favor à fonte única.
#### D2 — Ordem do job único e condições YAML (`ci-pr.yml` `checks`)
- **Ordem nova:** prep URL única (5432) → migrate + seed minimal (passos atuais) → `test:int` full/changed → **`pnpm db:reset`** (novo step) → build default OU build `.next-e2e` → e2e.
- **Condições novas:**
- Reset: `if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'` — só quando há fase de build.
- Build default: `if: build_mode != 'none' && e2e_mode != 'selected'` (hoje só `build_mode`) — só existe quando é o único build da run. No modo `full` (diff high-risk), `e2e_mode == 'full'` → o build default continua rodando e o e2e segue pulado por design (OPS72); o classifier confirma: `selectE2eSpecs` devolve `{mode:'full', specs:[]}`.
- Build `.next-e2e` e e2e: inalterados (`e2e_mode == 'selected'`).
- **Remover:** service `postgres-build`, prep/URL 5433 + migrate + seed. **Manter o nome do service `postgres-int`** (o allowlist de hosts segue válido; no runner hosted o alcance é via `localhost:5432` publicado — nome de bloco YAML não tem efeito funcional). Atualizar o comentário de cabeçalho que descreve os "two isolated services".
- **Matriz de casos (verificar no diff/review):** docs-only (tudo `none`) → migrate+seed rodam como hoje, reset/build/e2e skipam; PR com build sem e2e (mode `full`) → reset + build default; PR com e2e `selected` (com ou sem build surface) → reset + build `.next-e2e` (único) + e2e.
#### D3 — Migrate embutido no build
- **Opções:** A) aceitar o no-op — `pnpm build` re-roda `payload migrate` já aplicado; B) remover/condicionar o migrate do build para o CI.
- **Recomendação:** A — o migrate embutido é idempotente por design (Payload rastreia `payload_migrations`) e o `package.json` `build` é o mesmo binário que o `deploy-homeserver.sh` usa para o build de PROD (OPS66: migrate-before-build obrigatório); mexer aí fere o contrato da publicação. **Não tocar.**
- **Rejeitada:** B (variante com env/flag criaria dois caminhos de build; o contrato OPS66 é inegociável e o custo do no-op é de segundos).
- **Contabilização honesta do aceite "4× → ~2×":** ciclos completos de reconstrução de estado por run: 4 → 2 (baseline int + baseline build/e2e no MESMO Postgres). Postgres: 2 → 1. Builds: 2 → 1. Migrates executados (explícitos + embutidos): 4 → 3 — o residual é exatamente o no-op embutido deste ponto. Seeds: 2 → 2 (ver D5).
#### D4 — Verify do deploy
- **Opções:** A) mesmo setup único no job `verify` (`deploy.yml`), mantendo o fluxo full; B) reduzir o verify ao setup novo.
- **Recomendação:** A — rede de segurança da publicação inegociável. `verify` ganha: 1 service (remover `postgres-build`), prep URL única, migrate+seed, int full, **`pnpm db:reset`**, e **apenas** o build `.next-e2e` + e2e full — o build default sai porque no verify o e2e **sempre** roda, então o único build necessário é o que o Playwright serve. Job `deploy` (self-hosted homeserver) **intocado**.
- **Rejeitada:** B (verify reduzido = publicar com garantia menor; contradiz o aceite).
#### D5 — Seeds
- **Opções:** A) seed dentro do reset (drop → migrate → seed minimal); B) reset só drop+migrate (confiar que o seed baseline sobrevive ao int).
- **Recomendação:** A — o seed minimal é idempotente e o e2e depende do baseline seedado (paridade worktree/CI, OPS28 + guard `seedParity`; os pins de `priority`/`expectedVotes`/`campaignGoals` e os users sintéticos). Re-rodar o seed pós-int é a prova barata de baseline idêntico (a intenção exige: "quem executa deve provar o reset antes de confiar no banco único"). Custo ~10–30s; não otimizar fora disso.
- **Rejeitada:** B (depende de provar que nenhuma spec int toca rows do seed — consent rename, pins, goals — sem ganho que compense; a garantia "e2e e int compartilham o MESMO baseline" só vale com o seed no reset).
### Componentes / mudanças
- **`scripts/db-reset.mjs`** (novo): DDL `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` via pool `pg` (devDependency já presente) → `execFileSync('pnpm', ['migrate'])` → `execFileSync('pnpm', ['db:seed:minimal'])`; guard duplo fail-closed (host local via `assertLocalDatabase` + regex `_test`); log de fases; exit não-zero fail-fast. Reusa `loadCliEnv` de `scripts/lib/cli.mjs`.
- **`scripts/lib/cli.mjs`**: exporta o contrato `TEST_DATABASE_NAME_RE` (fonte única com `assertTestDatabase.ts`).
- **`package.json`**: `"db:reset": "node scripts/db-reset.mjs"` (família `db:*`). `package.json` já está em `HIGH_RISK_EXACT` → o PR desta entrega classifica `full` e roda int cheia + build.
- **`.github/workflows/ci-pr.yml`**: remove `postgres-build` + steps 5433; adiciona step "Reset database (single service)" com o `if:` de D2; ajusta `if:` do build default; atualiza comentário de serviços.
- **`.github/workflows/deploy.yml`** (job `verify`): espelho do acima, sem skips — remove `postgres-build`, remove build default, adiciona reset entre int e build `.next-e2e`.
- **`scripts/gate-ci.mjs`**: `pnpm db:reset` antes do build quando há fase de build — paridade local com o CI novo (o mirror deixa de construir sobre resíduo do int).
- **`scripts/lib/test-affected-core.mjs`**: `HIGH_RISK_EXACT` += `'scripts/db-reset.mjs'` — mudança no reset é harness de teste, deve rodar suíte cheia.
- **Docs:** entrada em `docs/changelog/<data>-ops88.md` + `pnpm changelog:build`; menção curta no AGENTS.md (bloco de CI) se o runbook do job mudar — o runbook de deploy (`docs/ops/teqo-1313-deploy.md`) **não** muda.
- **Migration:** sem migration (nenhum schema/dado novo).
- **Access / Consent:** nenhum (nenhuma chave de Consent tocada; fail-closed intacto).
- **UI:** N/A — sem UI (Impeccable A).
## Fases verificáveis
1. **Tracer — script de reset** (quota principal): `scripts/db-reset.mjs` + `pnpm db:reset` + contrato compartilhado. Verificar localmente num banco de teste: (a) banco com resíduo (rodar `pnpm test:int` full antes) → reset → schema zerado, migrate+seed reaplicados, 435 municípios e pins presentes; (b) idempotência (re-execução OK); (c) guards: recusa `teqo` (sem `_test`) e host remoto; (d) `pnpm test:int` full verde pós-reset.
2. **Workflows e mirror**: editar `ci-pr.yml`, `deploy.yml`, `gate-ci.mjs`; verificar a matriz de casos de D2 no diff; `pnpm gate:ci` local (inclui o reset novo) verde. O caminho e2e novo do PR só é exercitado por um PR `selected` — o PR desta entrega classifica `full` (package.json), então a prova do caminho e2e é: local `pnpm db:reset && NEXT_DIST_DIR=.next-e2e pnpm build` + `pnpm test:e2e:affected` (skill, OPS72) e, pós-merge, um `deploy` manual (verify full) como prova canônica.
3. **Gates**: `pnpm gate:fast` na iteração; `pnpm check:cycles`/`format:check`/`knip`; changelog entry; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar em `playwright.config.ts`, `next.config.mjs`, workers/retries, suítes/specs, classifier de e2e (OPS86) nem níveis de teste (OPS87).
- Não alterar o script `build` do `package.json` nem `deploy-homeserver.sh` (contrato OPS66) — D3.
- Não renomear o service para `postgres` (churn no allowlist de hosts sem ganho funcional).
- Não dar `if:` aos passos migrate+seed iniciais para pular docs-only PRs (comportamento atual preservado; fora do outcome).
- Não adicionar artefato/shared de `.next` entre fases (job único não precisa; fora de escopo).
- Não mexer no lease de banco (`testDatabaseLease.ts` é serialização intra-run via advisory lock — não é reset).
- Não rodar `pnpm db:reset` contra `teqo` (dev local sem `_test`) — o guard é obrigatório, não um aviso.
- Não engenhar fallback se o reset falhar no meio (drop feito, migrate falhou): a run falha e a re-run parte da VM nova com service fresco — estado limpo por construção.
## Riscos e mitigação
- **Resíduo não coberto pelo drop schema** (sequences/views/grants): `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` cobre tabelas, views e sequences do schema; extensions permanecem (as mesmas de sempre); migrates reaplicam o schema completo. Verificação no tracer (1a) roda int full antes do reset — o caso real.
- **PR desta entrega não exercita o caminho e2e do CI** (classifica `full`): mitigado por prova local (`db:reset` + build `.next-e2e` + `test:e2e:affected`) e pelo deploy verify manual pós-merge.
- **Custo do reset (~10–30s por run)**: mais que compensado pela remoção de um Postgres boot + um ciclo migrate+seed completo (~30–60s+); líquido negativo. Medir o job na 1ª run pós-merge.
- **Condição YAML errada (matriz de casos)**: as condições são poucas e exclusivas (reset/build-default/e2e-build); revisar a matriz de D2 no PR body; `ciSkipInvariants.unit.spec.ts` cobre o classifier, não o YAML — o checklist manual fecha o gap.
- **Reset com conexões ativas**: o reset roda entre fases, quando vitest/playwright já saíram; o pool `pg` fecha após o DDL; sem concorrência possível na ordem serial do job.
- **`psql` ausente no runner hosted**: resolvido por construção (DDL via `pg` em node).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: 1 Postgres por run com reset determinístico entre fases; 1 build quando build+e2e rodam juntos (default só quando é o único); verify do deploy full com setup compartilhado; deploy homeserver intacto; mensurável — ciclos 4× → ~2×, builds 2 → 1
- [ ] Invariantes AGENTS/engineering-standards: guard de banco local/`_test` fail-closed no novo script; nenhum schema/access/Consent tocado; contratos públicos, OPS66 e guards existentes preservados
- [ ] Testes de domínio previstos: unit do contrato do guard (se a const compartilhada nascer em `cli.mjs`), int full local pós-reset (pins 435/`catalog+1`), e2e local pós-reset (affected), `gate-ci` local com o reset
---
**Self-score decision-quality: 5/5**
1. Decisões caras têm rejeitadas? Sim — as 5 decisões (D1–D5) têm Opções/Recomendação/Rejeitadas explícitas.
2. Cabe no appetite? Sim — um script novo + edits em 2 workflows/mirror/manifest, com prova local, dentro de ~0,5–1 dia.
3. Rabbit holes nomeados? Sim — 9 cortes explícitos de engenharia, cada um com o dono do contrato.
4. Depth check: reusa helpers existentes? Sim — `assertLocalDatabase`, `loadCliEnv`, `pg`, família `db:*`, padrão `execFileSync` do `gate-ci.mjs`, `HIGH_RISK_EXACT` e o regex de `assertTestDatabase` compartilhado.
5. Aceite de produto preservado? Sim — nenhuma redução de suíte, e2e sempre em build de produção, OPS66 e deploy homeserver intactos, com contabilização honesta do "4× → ~2×".

View file

@ -26,6 +26,7 @@
"db:stop": "docker compose -p teqo down",
"db:doctor": "node scripts/db-doctor.mjs",
"db:pull": "node scripts/db-pull.mjs",
"db:reset": "node scripts/db-reset.mjs",
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",

77
scripts/db-reset.mjs Normal file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Deterministic test-database reset (OPS88) — one Postgres per run, a clean
* baseline between phases.
*
* `DROP SCHEMA public CASCADE` + `CREATE SCHEMA public`, then `pnpm migrate`
* and `pnpm db:seed:minimal` rebuild the exact baseline a fresh service would
* have (migrations re-seed the 435-municipality catalog; the minimal seed is
* idempotent). CI runs it between the integration phase and the build/e2e
* phase — this replaces the former second Postgres container whose only job
* was hiding int fixture residue from the e2e.
*
* Safety: drops data BY DESIGN, so it is stricter than the other db scripts —
* there is NO ALLOW_REMOTE_DB escape here. It refuses everything but a LOCAL
* database whose name matches TEST_DATABASE_NAME_RE (`teqo_test` or
* `teqo_<worktree>_test`). The dev database `teqo` and anything remote fail
* closed. (assertLocalDatabase is not used on purpose: its remote override
* must never arm a DROP SCHEMA.)
*
* Usage:
* DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm db:reset
* (In CI the step env carries DATABASE_URL from the earlier prep step.)
*/
import { execFileSync } from 'node:child_process'
import pg from 'pg'
import { dieWithLabel, loadCliEnv, LOCAL_HOSTS, TEST_DATABASE_NAME_RE } from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('db:reset')
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to continue.')
let parsedUrl
try {
parsedUrl = new URL(databaseUrl)
} catch {
die('DATABASE_URL is not a valid PostgreSQL connection string.')
}
if (parsedUrl.protocol !== 'postgresql:') {
die(`Refusing to reset over protocol "${parsedUrl.protocol || '(unknown)'}".`)
}
if (!LOCAL_HOSTS.has(parsedUrl.hostname)) {
die(
`Refusing to reset on a non-local host ("${parsedUrl.hostname}"). ` +
'This script drops the schema — there is no remote override by design.',
)
}
const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, ''))
if (!TEST_DATABASE_NAME_RE.test(databaseName)) {
die(
`Refusing to reset database "${databaseName}" — the name must match ` +
'`teqo_test` or `teqo_<worktree>_test` (TEST_DATABASE_NAME_RE). The dev database is never reset.',
)
}
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 })
try {
console.log(`[db:reset] Dropping schema public on "${databaseName}"…`)
await pool.query('DROP SCHEMA public CASCADE')
await pool.query('CREATE SCHEMA public')
console.log('[db:reset] Schema rebuilt — running migrate…')
} finally {
await pool.end()
}
execFileSync('pnpm', ['migrate'], { stdio: 'inherit' })
console.log('[db:reset] Migrations applied — seeding minimal baseline…')
execFileSync('pnpm', ['db:seed:minimal'], { stdio: 'inherit' })
console.log('[db:reset] ✓ deterministic baseline ready (migrate + db:seed:minimal).')

View file

@ -156,6 +156,15 @@ const main = async () => {
console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)')
}
// OPS88: mirror of the CI single-Postgres reset — the build phase starts
// from the same clean baseline (drop schema → migrate → seed:minimal) the
// e2e build would get in CI, instead of int fixture residue.
if (scope.build.mode === 'none' && scope.e2e.mode !== 'selected') {
console.log('\n[gate:ci] ⊘ db:reset skipped (no build/e2e phase)')
} else {
run('db:reset', 'pnpm', ['db:reset'], dbEnv)
}
if (scope.build.mode === 'none') {
console.log('\n[gate:ci] ⊘ build skipped (no build surface)')
} else {

View file

@ -87,6 +87,14 @@ export const defaultGatewayHost = () => {
const gatewayHost = defaultGatewayHost()
if (gatewayHost) LOCAL_HOSTS.add(gatewayHost)
/**
* The one test-database name contract: `teqo_test` or `teqo_<worktree>_test`.
* Shared by `tests/helpers/assertTestDatabase.ts` (test suite guard) and
* `scripts/db-reset.mjs` (OPS88) so a DROP-SCHEMA script can never accept the
* dev database `teqo` or any non-test name.
*/
export const TEST_DATABASE_NAME_RE = /^teqo(_[a-z0-9]+)?_test$/
export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB'
/**

View file

@ -41,6 +41,7 @@ export const HIGH_RISK_EXACT = new Set([
'scripts/run-e2e-affected.mjs',
'scripts/vitest-changed-or-full.mjs',
'scripts/ci-scope.mjs',
'scripts/db-reset.mjs',
'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',

View file

@ -9,14 +9,13 @@
* looks test-like.
*/
/** `teqo_test`, `teqo_wt15_test`, … — the _test suffix is the hard contract. */
const TEST_DATABASE_NAME = /^teqo(_[a-z0-9]+)?_test$/
// Self-hosted Forgejo runner: services are reached by NAME on the job network
// (postgres-int / postgres-build, OPS62 X1) or, on the legacy per-port
// publish, through the bridge gateway IP (RFC1918). Same source as the CLI
// guard so both guards admit exactly the same hosts (OPS50/OPS62).
import { defaultGatewayHost } from '../../scripts/lib/cli.mjs'
// TEST_DATABASE_NAME_RE is the shared name contract (cli.mjs) — the same
// regex `scripts/db-reset.mjs` uses before dropping the schema (OPS88).
import { defaultGatewayHost, TEST_DATABASE_NAME_RE } from '../../scripts/lib/cli.mjs'
export function assertTestDatabase(databaseUrl: string | undefined): void {
if (!databaseUrl) {
@ -47,7 +46,7 @@ export function assertTestDatabase(databaseUrl: string | undefined): void {
if (
parsedUrl.protocol !== 'postgresql:' ||
!TEST_DATABASE_NAME.test(databaseName) ||
!TEST_DATABASE_NAME_RE.test(databaseName) ||
!localHosts.has(parsedUrl.hostname)
) {
throw new Error(

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { defaultGatewayHost, LOCAL_HOSTS } from '../../scripts/lib/cli.mjs'
import { defaultGatewayHost, LOCAL_HOSTS, TEST_DATABASE_NAME_RE } from '../../scripts/lib/cli.mjs'
describe('LOCAL_HOSTS (OPS50/OPS62 — self-hosted runner services by name, legacy gateway)', () => {
it('still contains the canonical static hosts', () => {
@ -32,3 +32,20 @@ describe('LOCAL_HOSTS (OPS50/OPS62 — self-hosted runner services by name, lega
expect(LOCAL_HOSTS.has('postgres-prod')).toBe(false)
})
})
describe('TEST_DATABASE_NAME_RE (OPS88 — the one _test name contract, shared with db:reset)', () => {
it('admits the suite database and per-worktree test databases', () => {
expect(TEST_DATABASE_NAME_RE.test('teqo_test')).toBe(true)
expect(TEST_DATABASE_NAME_RE.test('teqo_wt88_test')).toBe(true)
expect(TEST_DATABASE_NAME_RE.test('teqo_wt999_test')).toBe(true)
})
it('rejects the dev database and any non-test name', () => {
expect(TEST_DATABASE_NAME_RE.test('teqo')).toBe(false)
expect(TEST_DATABASE_NAME_RE.test('teqo_wt88')).toBe(false)
expect(TEST_DATABASE_NAME_RE.test('teqo_prod')).toBe(false)
expect(TEST_DATABASE_NAME_RE.test('teqo_test2')).toBe(false)
expect(TEST_DATABASE_NAME_RE.test('teqo_Test_test')).toBe(false)
expect(TEST_DATABASE_NAME_RE.test('')).toBe(false)
})
})