diff --git a/docs/plans/gate-push-local.md b/docs/plans/gate-push-local.md index 61a763f5..0b358b63 100644 --- a/docs/plans/gate-push-local.md +++ b/docs/plans/gate-push-local.md @@ -28,7 +28,7 @@ Decisão de produto (2026-07-30, brief do lote CI): scripts DRY (`gate:fast`, `g ## Decisões travadas -- **knip fora do pre-push.** *(Revertido 2026-07-30 — `gate:ci` inclui knip para espelhar ci-pr.)* O grafo do knip é incompleto (não carrega `payload.config.ts`, ledger P3) e é lento para um hook; CI já o roda como erro bloqueante. **Rejeitado na v1:** knip no hook (custo por push + falsos positivos já fichados). +- **knip fora do pre-push.** _(Revertido 2026-07-30 — `gate:ci` inclui knip para espelhar ci-pr.)_ O grafo do knip é incompleto (não carrega `payload.config.ts`, ledger P3) e é lento para um hook; CI já o roda como erro bloqueante. **Rejeitado na v1:** knip no hook (custo por push + falsos positivos já fichados). - **Sem pre-commit hook.** A divisão fica: nada no commit (commits intermediários de WIP são legítimos), tudo no push. **Rejeitado:** lint-staged no commit (adiciona toolchain e atrito sem pegar classe de erro que o push não pegue segundos depois). - **Escape hatch = `--no-verify` nativo, documentado; sem variável mágica própria.** **Rejeitado:** `SKIP_GATE=1` (mais uma convenção para manter; `--no-verify` é o padrão git que todo agente/humano já conhece). - **Hook chama `pnpm gate:push`, nunca inline de comandos.** Uma fonte de verdade editável em `package.json`. **Rejeitado:** manter a linha de shell no hook (foi o que causou o drift atual). diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index e20f2ca3..f59a3294 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -5,8 +5,8 @@ * `e2e-affected.mjs` vs `origin/stage`). Build always runs. `migration-lock` is * GitHub-only. * - * Requires local Postgres with `teqo_test` when tests or build run. - * Escape hatch: `git push --no-verify`. + * Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape: + * `git push --no-verify`. */ import { execFileSync, spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' @@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url' import { parse as parseEnv } from 'dotenv' +import { diagnoseDatabaseTarget } from './db-doctor.mjs' + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const readTestEnv = () => { @@ -57,73 +59,89 @@ const run = (label, command, args, env = process.env) => { } } -const testScope = readJsonScript('scripts/test-affected.mjs') -const e2eScope = readJsonScript('scripts/e2e-affected.mjs') +const main = async () => { + const testScope = readJsonScript('scripts/test-affected.mjs') + const e2eScope = readJsonScript('scripts/e2e-affected.mjs') -console.log(`[gate:ci] vitest: ${testScope.mode} — ${testScope.reason} (base ${testScope.base})`) -console.log(`[gate:ci] e2e: ${e2eScope.mode} — ${e2eScope.reason} (base ${e2eScope.base})`) -if (e2eScope.unmapped?.length > 0) { - console.error( - `[gate:ci] e2e unmapped src/ paths:\n ${e2eScope.unmapped.join('\n ')}`, - ) -} + console.log(`[gate:ci] vitest: ${testScope.mode} — ${testScope.reason} (base ${testScope.base})`) + console.log(`[gate:ci] e2e: ${e2eScope.mode} — ${e2eScope.reason} (base ${e2eScope.base})`) + if (e2eScope.unmapped?.length > 0) { + console.error(`[gate:ci] e2e unmapped src/ paths:\n ${e2eScope.unmapped.join('\n ')}`) + } -run('preflight (db:doctor)', 'pnpm', ['db:doctor']) -run('lint', 'pnpm', ['lint']) -run('format:check', 'pnpm', ['format:check']) -run('typecheck', 'pnpm', ['typecheck']) -run('knip', 'pnpm', ['exec', 'knip']) -run('check:cycles', 'pnpm', ['check:cycles']) + console.log('\n[gate:ci] ▶ preflight (teqo_test)') + const dbOk = await diagnoseDatabaseTarget({ + label: 'test', + databaseUrl: dbEnv.DATABASE_URL, + }) + if (!dbOk) { + console.error('\n[gate:ci] ✗ preflight (teqo_test) failed — run `pnpm db:start`') + process.exit(1) + } -if (testScope.mode === 'full') { - run('test:unit (full)', 'pnpm', ['test:unit']) -} else if (testScope.mode === 'changed') { - run('test:unit (changed)', 'pnpm', [ - 'test:unit', - '--', - '--changed', - testScope.base, - '--passWithNoTests', - ]) -} else { - console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)') -} + run('lint', 'pnpm', ['lint']) + run('format:check', 'pnpm', ['format:check']) + run('typecheck', 'pnpm', ['typecheck']) + run('knip', 'pnpm', ['exec', 'knip']) + run('check:cycles', 'pnpm', ['check:cycles']) -if (testScope.mode !== 'none') { - run('migrate', 'pnpm', ['migrate'], dbEnv) - run('db:seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv) if (testScope.mode === 'full') { - run('test:int (full)', 'pnpm', ['test:int']) - } else { - run('test:int (changed)', 'pnpm', [ - 'test:int', + run('test:unit (full)', 'pnpm', ['test:unit']) + } else if (testScope.mode === 'changed') { + run('test:unit (changed)', 'pnpm', [ + 'test:unit', '--', '--changed', testScope.base, '--passWithNoTests', ]) - } -} else { - console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)') -} - -run('build', 'pnpm', ['build'], dbEnv) - -if (e2eScope.mode === 'none') { - console.log('\n[gate:ci] ⊘ e2e skipped (no e2e blast radius)') -} else { - run('playwright install chromium', 'pnpm', ['exec', 'playwright', 'install', 'chromium']) - run('e2e migrate', 'pnpm', ['migrate'], dbEnv) - run('e2e seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv) - run('e2e build', 'pnpm', ['build'], { ...dbEnv, NEXT_DIST_DIR: '.next/e2e' }) - - const e2eEnv = { ...dbEnv, E2E_PROD: '1', CI: '1', NEXT_DIST_DIR: '.next/e2e' } - if (e2eScope.mode === 'full') { - run('test:e2e (full)', 'pnpm', ['test:e2e'], e2eEnv) } else { - const specPaths = e2eScope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) - run(`test:e2e (selected: ${e2eScope.specs.join(', ')})`, 'pnpm', ['test:e2e', '--', ...specPaths], e2eEnv) + console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)') } + + if (testScope.mode !== 'none') { + run('migrate', 'pnpm', ['migrate'], dbEnv) + run('db:seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv) + if (testScope.mode === 'full') { + run('test:int (full)', 'pnpm', ['test:int']) + } else { + run('test:int (changed)', 'pnpm', [ + 'test:int', + '--', + '--changed', + testScope.base, + '--passWithNoTests', + ]) + } + } else { + console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)') + } + + run('build', 'pnpm', ['build'], dbEnv) + + if (e2eScope.mode === 'none') { + console.log('\n[gate:ci] ⊘ e2e skipped (no e2e blast radius)') + } else { + run('playwright install chromium', 'pnpm', ['exec', 'playwright', 'install', 'chromium']) + run('e2e migrate', 'pnpm', ['migrate'], dbEnv) + run('e2e seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv) + run('e2e build', 'pnpm', ['build'], { ...dbEnv, NEXT_DIST_DIR: '.next/e2e' }) + + const e2eEnv = { ...dbEnv, E2E_PROD: '1', CI: '1', NEXT_DIST_DIR: '.next/e2e' } + if (e2eScope.mode === 'full') { + run('test:e2e (full)', 'pnpm', ['test:e2e'], e2eEnv) + } else { + const specPaths = e2eScope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) + run( + `test:e2e (selected: ${e2eScope.specs.join(', ')})`, + 'pnpm', + ['test:e2e', '--', ...specPaths], + e2eEnv, + ) + } + } + + console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, affected scope)') } -console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, affected scope)') +await main()