diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 871e6d66..8c906bc7 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fce50342..ec36e8ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/docs/CHANGELOG-AGENTS-HISTORY.md b/docs/CHANGELOG-AGENTS-HISTORY.md index b2be8a80..0ec2e55a 100644 --- a/docs/CHANGELOG-AGENTS-HISTORY.md +++ b/docs/CHANGELOG-AGENTS-HISTORY.md @@ -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(_)?_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. diff --git a/docs/changelog/2026-08-24-ops88.md b/docs/changelog/2026-08-24-ops88.md new file mode 100644 index 00000000..29c2b787 --- /dev/null +++ b/docs/changelog/2026-08-24-ops88.md @@ -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(_)?_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. diff --git a/docs/plans/ops88-ci-verify-setup-unico-impl.md b/docs/plans/ops88-ci-verify-setup-unico-impl.md new file mode 100644 index 00000000..6ecb619b --- /dev/null +++ b/docs/plans/ops88-ci-verify-setup-unico-impl.md @@ -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?
build_mode != 'none' || e2e selected"} + D -- não --> F["fim (PR docs-only)"] + D -- sim --> E["db:reset
(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/-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×". diff --git a/package.json b/package.json index 982953a6..cc3c3d18 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-reset.mjs b/scripts/db-reset.mjs new file mode 100644 index 00000000..342f7070 --- /dev/null +++ b/scripts/db-reset.mjs @@ -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__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__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).') diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index 625665a2..f437a338 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -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 { diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index ee70a470..3fd04834 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -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__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' /** diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 22c2b96e..b641b8b0 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/tests/helpers/assertTestDatabase.ts b/tests/helpers/assertTestDatabase.ts index ab94abaa..d238de2b 100644 --- a/tests/helpers/assertTestDatabase.ts +++ b/tests/helpers/assertTestDatabase.ts @@ -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( diff --git a/tests/unit/localHosts.unit.spec.ts b/tests/unit/localHosts.unit.spec.ts index aaad5cc0..00d45dcb 100644 --- a/tests/unit/localHosts.unit.spec.ts +++ b/tests/unit/localHosts.unit.spec.ts @@ -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) + }) +})