From e04db48742e35b944b5a1c53a023e73fb542554b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 30 Jul 2026 21:12:52 -0300 Subject: [PATCH] =?UTF-8?q?feat(ops):=20auto-promote=20stage=E2=86=92main?= =?UTF-8?q?=20e=20merge=20em=20stage=20sem=20exigir=20head=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ops): auto-promote stage→main e merge em stage sem exigir head Substitui o promote manual por workflow disparado no ci-stage green, relaxa strict na proteção de stage (script configure:branch-protection) e documenta o novo paradigma em AGENT-OPS e work-issue. * style: format AGENT-OPS and configure-branch-protection for ci-pr * feat(ops): pnpm push com ensure-repo-deps determinístico Bootstrap install + Husky antes de todo push (pnpm push e pre-push). Documenta fluxo canônico em work-issue, agent-pr-workflow e AGENT-OPS. * fix(ops): pnpm push roda gate no script, sem depender do Husky Cloud environment.json chama prepare explicitamente após install. * test(int): isolate advisor portfolio search from shared cairu slug Parallel int specs were stomping municipality.advisors on the fixed cairu row; allocate per-test municipalities instead. --- .cursor/environment.json | 2 +- .cursor/rules/agent-pr-workflow.mdc | 20 +++- .cursor/rules/engineering-standards.mdc | 2 +- .cursor/skills/work-issue/SKILL.md | 18 ++-- .../workflows/issue-done-on-stage-merge.yml | 2 +- .github/workflows/promote-stage-to-main.yml | 94 +++++++++++++++++++ .husky/pre-push | 4 +- docs/AGENT-OPS.md | 31 +++--- package.json | 2 + scripts/agent-promote.mjs | 33 ++----- scripts/configure-branch-protection.mjs | 53 +++++++++++ scripts/ensure-repo-deps.mjs | 57 +++++++++++ scripts/git-push.mjs | 36 +++++++ .../int/homeSearchMunicipalities.int.spec.ts | 15 +-- .../searchStaffMunicipalityHits.int.spec.ts | 15 +-- 15 files changed, 320 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/promote-stage-to-main.yml create mode 100644 scripts/configure-branch-protection.mjs create mode 100644 scripts/ensure-repo-deps.mjs create mode 100644 scripts/git-push.mjs diff --git a/.cursor/environment.json b/.cursor/environment.json index d7191b92..67c4bbd9 100644 --- a/.cursor/environment.json +++ b/.cursor/environment.json @@ -1,4 +1,4 @@ { - "install": "pnpm install --frozen-lockfile && bash ./.cursor/cloud-setup.sh", + "install": "pnpm install --frozen-lockfile && pnpm run prepare && bash ./.cursor/cloud-setup.sh", "start": "bash ./.cursor/ensure-postgres.sh" } diff --git a/.cursor/rules/agent-pr-workflow.mdc b/.cursor/rules/agent-pr-workflow.mdc index ee91de4c..b67e0da4 100644 --- a/.cursor/rules/agent-pr-workflow.mdc +++ b/.cursor/rules/agent-pr-workflow.mdc @@ -10,7 +10,7 @@ alwaysApply: true ## Create (Ready, base stage) ```bash -git push -u origin HEAD +pnpm push -u origin HEAD gh pr create --base stage --title " — " --body "$(cat <<'EOF' ## O que é <2–4 linhas> @@ -21,7 +21,23 @@ EOF ``` - **Never** `--draft`. PRs must be **Ready for review** on creation. -- Base is always **`stage`** (never `main` — promote is human-only via `pnpm agent:promote --i-am-human`). +- Base is always **`stage`** (never `main` — promote é automático via `promote-stage-to-main.yml` após CI stage green). + +## Push (determinístico) + +**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired: + +1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky` +2. `pnpm gate:push` — espelho do `ci-pr.yml` (inclui `format:check`) +3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch) + +Cursor Cloud já roda `pnpm install` em `.cursor/environment.json` (`install`); worktrees locais sem `pnpm i` dependem do passo 1. + +```bash +pnpm push -u origin HEAD # canonical +git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push +git push --no-verify # WIP escape — CI repete tudo +``` ## Auto-merge immediately diff --git a/.cursor/rules/engineering-standards.mdc b/.cursor/rules/engineering-standards.mdc index 5e1c4c7c..a7fddfce 100644 --- a/.cursor/rules/engineering-standards.mdc +++ b/.cursor/rules/engineering-standards.mdc @@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM 1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive. 2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment. -3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Pre-push hook** roda `pnpm gate:push` → `pnpm gate:ci` — espelho serial do `ci-pr.yml`: estático sempre; unit/int/e2e no **mesmo blast radius** que o CI (`scripts/test-affected.mjs`, `scripts/e2e-affected.mjs` vs `origin/stage`); build sempre. Fonte única: scripts `gate:*` em `package.json`; escape para WIP: `git push --no-verify`. `migration-lock` só existe no GitHub (conta PRs abertos em schema). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`). +3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml`, inclui `format:check`). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. `migration-lock` só existe no GitHub (conta PRs abertos em schema). O handoff humano (promote `stage→main`) é automático após CI stage green. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`). 4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.cursor/skills/work-issue/SKILL.md b/.cursor/skills/work-issue/SKILL.md index 89fc0af5..3ec42cd1 100644 --- a/.cursor/skills/work-issue/SKILL.md +++ b/.cursor/skills/work-issue/SKILL.md @@ -7,14 +7,14 @@ description: Conduz uma GitHub Issue rastreável do Teqo do claim ao merge em st Esta skill conduz UMA Issue rastreável do claim ao merge em `stage`. Substitui o antigo fluxo implement-roadmap-item + close-delivery + ship-to-main. **GitHub Issues são a fonte canônica** de spec/status/deps/prio/modelo; `docs/roadmap.md` é legado congelado e nunca é editado aqui (registro de entrega vai na Issue + `docs/plans/<slug>.md` + notebook do projeto). -**Proibido neste fluxo:** `pnpm agent:promote` (promote stage→main é humano), qualquer acesso a DB de stage/prod, merge sem CI green, editar outras Issues `in-progress`. +**Proibido neste fluxo:** qualquer acesso a DB de stage/prod, merge sem CI green, editar outras Issues `in-progress`. `pnpm agent:promote` é só override manual — o promote normal é automático após CI stage green. **Qualidade de decisão e dados:** aplique [decision-quality.md](../plan-issue/decision-quality.md) ao auditar e fatiar (caro vs barato, Opções+Recomendação+rejeitadas, appetite, rabbit holes, tracer bullet cedo) e [data-presentation.md](../plan-issue/data-presentation.md) ao auditar/fatiar superfícies com KPI/mapa/série/ranking. ## Checklist do fluxo ``` -- [ ] 0. Prep: pnpm i (node_modules obrigatório para agent:claim) +- [ ] 0. Prep: `pnpm i` antes do `agent:claim` (Cloud Agents já rodam via `.cursor/environment.json` → `install`; worktrees locais: obrigatório, ou deixe para o `ensure-repo-deps` do `pnpm push`) - [ ] 1. Claim: pnpm agent:claim (ou -- --issue <N>) — o brief do stdout é o contrato - [ ] 1b. Renomear a sessão (cursor-app-control rename_chat) — padrão `#<N> <id> — <título>` - [ ] 1c. Abrir o plano no editor (cursor-app-control open_resource) para o humano acompanhar @@ -22,7 +22,7 @@ Esta skill conduz UMA Issue rastreável do claim ao merge em `stage`. Substitui - [ ] 3. Freshness audit (enxuto) do plano contra o repositório - [ ] 4. Executar as fases (schema/server → UI via /impeccable → gates de engenharia) - [ ] 5. /simplify no diff da sessão → capture-review-debts -- [ ] 6. Fechar em stage: pnpm gate:push → branch → commit → gh pr create --base stage (Closes #N, sem --draft) +- [ ] 6. Fechar em stage: branch → commit → **`pnpm push -u origin HEAD`** (não `git push` nu) → gh pr create --base stage (Closes #N, sem --draft) → gh pr merge --auto --merge → gh pr checks --watch --required até o merge → consolidar pontas soltas ``` @@ -109,7 +109,7 @@ Ordem fixa, fases pequenas e verificáveis, respeitando o appetite do plano: 1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado. 2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft. -3. **Gates de engenharia** — iteração: `pnpm gate:fast`. Antes do push o hook roda `pnpm gate:push` (= `gate:ci`: estático + build sempre; unit/int/e2e no blast radius do CI via `test-affected`/`e2e-affected`). Scan Aikido dos arquivos editados. Comandos bare, nunca piped. Escape WIP: `git push --no-verify`. +3. **Gates de engenharia** — iteração: `pnpm gate:fast`. Push: **`pnpm push`** (ensure-deps + `gate:push` no script — não depende do hook Husky). `pnpm gate:push` manual só para debug sem push. Scan Aikido dos arquivos editados. Comandos bare, nunca piped. Escape WIP: `git push --no-verify` sem passar por `pnpm push`. Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo. @@ -138,13 +138,13 @@ Lance **três** subagentes via `Task` **em paralelo** na mesma mensagem — read ## Passo 6 — Fechar em stage -1. `pnpm gate:push` verde (ou `pnpm gate:ci` — espelho do ci-pr; o hook pre-push roda na sequência do `git push`). -2. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos). +1. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos). +2. **`pnpm push -u origin HEAD`** — canonical; roda `ensure-repo-deps` + `gate:push` + push (não depende do Husky). Não use `git push` nu. 3. `gh pr create --base stage` com `Closes #<N>` no body — **nunca `--draft`** (`.cursor/rules/agent-pr-workflow.mdc`). -4. `gh pr merge --auto --merge <PR>` imediatamente após criar o PR. -5. **Acompanhe só os checks obrigatórios** (`gh pr checks <PR> --watch --required`): gate = `checks` + `migration-lock`; Vercel não bloqueia. Falha no ci-pr → corrige na mesma branch. +4. `gh pr merge --auto --merge <PR>` imediatamente após criar o PR (não precisa rebasear na head de `stage` se não houver conflito — `strict=false` na proteção de `stage`). +5. **Acompanhe só os checks obrigatórios** (`gh pr checks <PR> --watch --required`): gate = `checks` + `migration-lock`; Vercel não bloqueia. Falha no ci-pr → corrige na mesma branch. **Conflito de merge** → rebase em `stage` e reempurre; não force-push salvo para destravar CI após rebase. 6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/<slug>.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto. ## Resumo final ao usuário -Issue trabalhada + sessão renomeada + plano aberto no editor + verificação de modelo (declarado vs sessão, ou registro do ausente), veredito do freshness audit, o que entrou por fase, resultado do critique/polish (se UI), simplify (3 reviewers paralelos) + débitos registrados, gates, link do PR e estado do merge em stage, pontas soltas consolidadas. Nunca anuncie promote — `in-prod` é humano (`pnpm agent:promote --i-am-human`). +Issue trabalhada + sessão renomeada + plano aberto no editor + verificação de modelo (declarado vs sessão, ou registro do ausente), veredito do freshness audit, o que entrou por fase, resultado do critique/polish (se UI), simplify (3 reviewers paralelos) + débitos registrados, gates, link do PR e estado do merge em stage, pontas soltas consolidadas. `in-prod` entra no auto-promote (`promote-stage-to-main.yml`) após CI stage green — não é passo do agente. diff --git a/.github/workflows/issue-done-on-stage-merge.yml b/.github/workflows/issue-done-on-stage-merge.yml index c03b877e..2fe239cd 100644 --- a/.github/workflows/issue-done-on-stage-merge.yml +++ b/.github/workflows/issue-done-on-stage-merge.yml @@ -24,5 +24,5 @@ jobs: for n in $numbers; do gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done || true gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \ - --body "Merged em stage via PR #${PR_NUMBER} — done; \`in-prod\` entra no promote humano." || true + --body "Merged em stage via PR #${PR_NUMBER} — done; \`in-prod\` entra no auto-promote após CI stage green." || true done diff --git a/.github/workflows/promote-stage-to-main.yml b/.github/workflows/promote-stage-to-main.yml new file mode 100644 index 00000000..39e1ca77 --- /dev/null +++ b/.github/workflows/promote-stage-to-main.yml @@ -0,0 +1,94 @@ +# Auto-promote: when ci-stage is green on the current `stage` head, merge stage → main. +# Replaces the manual `pnpm agent:promote --i-am-human` gate. The human review on main +# is replaced by ci-stage (Neon smoke) + ci.yml on main (full safety net post-merge). +# +# Stale runs are ignored: only the workflow run whose headSha still matches +# origin/stage may promote (newest commit wins when ci-stage queues). +name: Promote stage to main + +on: + workflow_run: + workflows: ['CI (stage)'] + types: [completed] + branches: [stage] + +concurrency: + group: promote-stage-to-main + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + promote: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + # Admin PAT bypasses main branch protection (required review + status checks on + # the promote PR). Falls back to GITHUB_TOKEN when the PAT is not configured. + GH_TOKEN: ${{ secrets.PROMOTE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + RUN_SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Promote only when this run is still the stage head + id: gate + run: | + set -euo pipefail + git fetch origin main stage + stage_sha="$(git rev-parse origin/stage)" + main_sha="$(git rev-parse origin/main)" + + if [ "$RUN_SHA" != "$stage_sha" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Stale ci-stage run ($RUN_SHA); current stage head is $stage_sha — skip." + exit 0 + fi + + if git merge-base --is-ancestor origin/main origin/stage; then + if [ "$main_sha" = "$stage_sha" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "main already at stage head ($stage_sha) — nothing to promote." + exit 0 + fi + else + echo "::error::main diverged from stage (hotfix on main?). Merge main into stage, wait for ci-stage green, then re-run." + exit 1 + fi + + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Promoting stage $stage_sha → main ($main_sha)." + + - name: Merge stage into main + if: steps.gate.outputs.skip != 'true' + run: | + set -euo pipefail + pr="$(gh pr list --state open --base main --head stage --json number --jq '.[0].number // empty')" + if [ -z "$pr" ]; then + gh pr create \ + --base main \ + --head stage \ + --title "Promote stage → main (auto)" \ + --body "Auto-promote após CI (stage) verde. Gate: ci-stage no head de stage. Rede pós-merge: ci.yml em main." + pr="$(gh pr list --state open --base main --head stage --json number --jq '.[0].number')" + fi + + # --admin bypasses the human review + pre-merge status checks on main; ci-stage + # is the intentional pre-promote gate (see docs/AGENT-OPS.md). + gh pr merge "$pr" --admin --merge + echo "Promoted via PR #$pr." + + - name: Label promoted issues in-prod + if: steps.gate.outputs.skip != 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + for n in $(gh issue list --state open --label done --limit 200 --json number --jq '.[].number'); do + gh issue edit "$n" --add-label in-prod --remove-label done || true + done diff --git a/.husky/pre-push b/.husky/pre-push index b56772da..9c85e3a2 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,3 @@ -# Full ci-pr mirror before push (source of truth: package.json `gate:push` → `gate:ci`). -# Escape hatch para WIP: `git push --no-verify`. +# For `git push` after `pnpm i` — same gate as `pnpm push`. Agents: use `pnpm push` (runs gate in-script; does not need this hook). +node scripts/ensure-repo-deps.mjs pnpm gate:push diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 9bf5b97e..8a92b893 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -1,6 +1,6 @@ # AGENT-OPS — operação do paradigma de agentes paralelos -Uma página. Norte: simplicidade (só GitHub + Cursor), agente para no merge a `stage`, `main`/prod só via promote humano. +Uma página. Norte: simplicidade (só GitHub + Cursor), agente para no merge a `stage`, `main`/prod via auto-promote quando o CI stage fica verde. ## Ambientes de dados (test ladder — nunca misturar) @@ -15,20 +15,22 @@ Agentes **nunca** recebem `DATABASE_URL` de stage/prod e nunca setam `ALLOW_REMO ## Fluxo ```text -claim → feature branch → PR --base stage → CI PR green → auto-merge em stage -stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main → Vercel prod +claim → feature branch → PR --base stage → CI PR green → auto-merge em stage (sem exigir estar na head de stage; conflito = rebase) +stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml) → main → Vercel prod ``` **Skills do fluxo (desde 2026-07-30):** `plan-issue` (ideia → plano completo + gate de confirmação + Issue via `agent:register --model`) → `work-issue` (claim → verificação de modelo → freshness audit → execução → /simplify → PR `--base stage` → acompanha CI até o merge) → `project-status` (overview/fila/mermaid/bloqueios/consolidação, read-only). `docs/roadmap.md` = **legado congelado** (stub); a fonte canônica são as GitHub Issues. -- **Agente faz sozinho:** `pnpm agent:claim` → implementa → `pnpm gate:push` (hook pre-push = espelho ci-pr) → `gh pr create --base stage` com `Closes #N` (**Ready**, sem `--draft`) → `gh pr merge --auto --merge` → `gh pr checks --watch --required` até o merge. Regra PR: `.cursor/rules/agent-pr-workflow.mdc`. +- **Agente faz sozinho:** `pnpm agent:claim` → implementa → **`pnpm push`** (bootstrap deps/hooks + hook `gate:push`) → `gh pr create --base stage` com `Closes #N` (**Ready**, sem `--draft`) → `gh pr merge --auto --merge` → `gh pr checks --watch --required` até o merge. Regra PR: `.cursor/rules/agent-pr-workflow.mdc`. ### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50) **Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`). -- **Só humano:** `pnpm agent:promote --i-am-human` (PR `stage→main` + merge com CI stage + ci-pr green; recusa se `main` divergiu de `stage` — merge main em stage antes), `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. -- Se `main` divergir de `stage` (hotfix): merge/rebase de `main` em `stage` + CI stage green de novo, só depois promote. +- **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual de emergência (mesmas verificações, sem esperar o workflow). +- **Auto-promote:** `promote-stage-to-main.yml` dispara quando `ci-stage.yml` termina com sucesso. Só promove se o `headSha` do run ainda for o head de `stage` (commit mais novo ganha quando o CI enfileira). Recusa se `main` divergiu de `stage` (hotfix: merge `main` em `stage` + CI stage green de novo). +- **Merge em stage sem estar na head:** branch protection de `stage` com `strict=false` — PR pode auto-merger com CI green mesmo que outro PR tenha entrado antes; conflito de merge continua bloqueando (rebase obrigatório). Aplicar/reparar: `pnpm configure:branch-protection`. +- **Secret opcional:** `PROMOTE_GITHUB_TOKEN` (PAT admin do repo) se `GITHUB_TOKEN` não conseguir `gh pr merge --admin` na proteção de `main`. Settings → Actions → "Allow GitHub Actions to create and approve pull requests" também ajuda. - **Engineering-audit é solitário:** quando roda, o paralelismo pausa (nenhum outro agente no repo) e o agente do audit executa as remediações P0/P1 na mesma sessão; inclui o harvest `kind:agent-miss` → guardrails. ## Comandos @@ -41,7 +43,9 @@ stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main | `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca a label `prio:*` | | `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) | | `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) | -| `pnpm agent:promote -- --i-am-human` | **Humano:** PR `stage→main` + merge se CI green | +| `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `stage→main` + merge se CI stage green (auto-promote faz isso sozinho) | +| `pnpm push [-- -u origin HEAD …]` | **Canônico:** `ensure-repo-deps` + `gate:push` + `git push --no-verify` (gate no script — não depende do Husky) | +| `pnpm configure:branch-protection [-- --dry-run]` | Ajusta proteção de `stage` (`strict=false`) e `main` (0 reviews — auto-promote substitui o humano) | | `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) | | `pnpm db:refresh:stage` | **Humano:** nova Neon branch `stage` de prod + swap do secret `STAGE_DATABASE_URL` (requer `NEON_API_KEY`) | @@ -56,13 +60,14 @@ Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:fe ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| -------------- | ------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock`; job rollup **`checks`** agrega todos (nome legado exigido pela branch protection — não re-serializa a suíte) | -| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto, estourou timeout 2× em 2026-07-30; a full fica no ci-pr/ci.yml) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal | -| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full | +| Workflow | Trigger | Banco | Passos | +| --------------------------- | --------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock`; job rollup **`checks`** agrega todos (nome legado exigido pela branch protection — não re-serializa a suíte) | +| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **smoke** `migrate` + subset int curado contra snapshot Neon (não é suíte completa — full int estourou timeout 2× em 2026-07-30). Gate de **promote automático** para `main`. **NUNCA `pnpm build`** | +| `promote-stage-to-main.yml` | ci-stage success em `stage` | — | auto-merge `stage→main` se head ainda é o de `stage`; marca Issues `done` → `in-prod` | +| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full | -Fast gate local do agente (iteração): `pnpm gate:fast` (lint + typecheck + test:unit). O hook `.husky/pre-push` roda `pnpm gate:push` → `scripts/gate-ci.mjs` — **espelho serial do `ci-pr.yml`**: estático + build sempre; unit/int/e2e no **mesmo blast radius** que o CI de feature (`test-affected.mjs` / `e2e-affected.mjs` vs `origin/stage`). Requer Postgres local com `teqo_test` (`pnpm db:start` ou `.cursor/ensure-postgres.sh` no Cloud). `migration-lock` só no GitHub. Escape WIP: `git push --no-verify`. Debug e2e isolado: `pnpm test:e2e:affected`. +Fast gate local do agente (iteração): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` + `gate:push` no script + push (`--no-verify` só porque o gate já rodou). Hook `.husky/pre-push` repete o mesmo para quem usa `git push` após `pnpm i`. Cursor Cloud: `pnpm install` já está em `.cursor/environment.json` (`install`). Requer Postgres local com `teqo_test`. Escape WIP: `git push --no-verify` sem `pnpm push`. Debug e2e: `pnpm test:e2e:affected`. ## Stage DB — runbook de refresh (semanal, humano) diff --git a/package.json b/package.json index 914b109c..fb789d6c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "agent:file-miss": "node scripts/agent-file-miss.mjs", "agent:promote": "node scripts/agent-promote.mjs", "agent:pool": "node scripts/agent-pool.mjs", + "configure:branch-protection": "node scripts/configure-branch-protection.mjs", "build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs", "build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs", "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", @@ -50,6 +51,7 @@ "gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit", "gate:ci": "node scripts/gate-ci.mjs", "gate:push": "pnpm gate:ci", + "push": "node scripts/git-push.mjs", "check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src", "generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types", diff --git a/scripts/agent-promote.mjs b/scripts/agent-promote.mjs index 625bf292..d8c3d583 100644 --- a/scripts/agent-promote.mjs +++ b/scripts/agent-promote.mjs @@ -1,17 +1,15 @@ /** - * `pnpm agent:promote -- --i-am-human` — promote `stage` → `main` (production). + * `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`. * - * HUMAN-ONLY. The flag is the review: branch protection on `main` requires a - * human review, and this script is it. It refuses to run without the flag and - * never runs from Cursor Automations. + * Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green. + * Use this only when the workflow failed or you need to promote out of band. * * Checks (in order): * 1. `main` has not diverged from `stage` (hotfix direto em main exige antes * merge/rebase de main em stage + CI stage green de novo). * 2. Latest ci-stage run on `stage` is green. - * 3. Creates the PR stage→main if none is open, then requires the PR checks - * (ci-pr: checks + migration-lock) green before merging. - * 4. Merges. Labels on done issues gain `in-prod` for the promoted range. + * 3. Creates the PR stage→main if none is open, then merges (ci-stage is the + * pre-promote gate; ci.yml on main is the post-merge safety net). */ import { execFileSync } from 'node:child_process' @@ -23,9 +21,9 @@ const { flags } = parseArgs(process.argv.slice(2), new Set()) if (flags['i-am-human'] !== true) { die( - 'Refused: promote is human-only. Re-run as a human with:\n' + + 'Refused: promote override is human-only. Re-run as a human with:\n' + ' pnpm agent:promote -- --i-am-human\n' + - 'Agents stop at merge-to-stage. Promote moves stage → main → produção (Vercel).', + 'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green.', ) } @@ -70,7 +68,7 @@ if ( } console.log('[agent:promote] CI stage green ✓') -// 3. PR stage → main. +// 3. PR stage → main (create if needed). let pr = ghJson([ 'pr', 'list', @@ -94,7 +92,7 @@ if (!pr) { '--title', `Promote stage → main (${new Date().toISOString().slice(0, 10)})`, '--body', - 'Promote humano via `pnpm agent:promote --i-am-human`. CI stage green verificado antes da criação.', + 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green verificado antes do merge.', ]) pr = { number: url.split('/').pop(), url } console.log(`[agent:promote] created PR ${pr.url}`) @@ -102,17 +100,6 @@ if (!pr) { console.log(`[agent:promote] reusing open PR ${pr.url}`) } -// 4. PR checks green, then merge (the human running this script IS the review -// required by main's branch protection; --admin applies that judgment). -const checks = ghJson(['pr', 'checks', String(pr.number), '--json', 'name,state']) -const notGreen = checks.filter((check) => check.state !== 'SUCCESS') -if (notGreen.length > 0) { - die( - `PR #${pr.number} ainda tem checks não-verdes: ${notGreen - .map((check) => `${check.name}=${check.state}`) - .join(', ')}. Espere o ci-pr terminar e re-rode o promote.`, - ) -} - +// 4. Merge (ci-stage was the pre-promote gate; ci.yml on main is the post-merge safety net). gh(['pr', 'merge', String(pr.number), '--admin', '--merge']) console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`) diff --git a/scripts/configure-branch-protection.mjs b/scripts/configure-branch-protection.mjs new file mode 100644 index 00000000..b6c74215 --- /dev/null +++ b/scripts/configure-branch-protection.mjs @@ -0,0 +1,53 @@ +/** + * One-time / drift repair for branch protection aligned with auto-promote + parallel agents. + * + * pnpm configure:branch-protection # apply + * pnpm configure:branch-protection -- --dry-run + * + * Changes: + * - stage: strict=false (allow merge without being up-to-date; conflicts still block) + * - main: required_approving_review_count=0 (auto-promote replaces human review) + * + * Auto-promote still needs secrets.PROMOTE_GITHUB_TOKEN (admin PAT) on the repo if + * GITHUB_TOKEN cannot bypass main protection — see docs/AGENT-OPS.md. + */ + +import { execFileSync } from 'node:child_process' + +import { parseArgs } from './lib/agent-github.mjs' + +const { flags } = parseArgs(process.argv.slice(2), new Set()) +const dryRun = flags['dry-run'] === true + +const repo = execFileSync( + 'gh', + ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], + { + encoding: 'utf8', + }, +).trim() + +const ghApiJson = (method, path, body) => { + const payload = JSON.stringify(body) + if (dryRun) { + console.log(`[dry-run] gh api -X ${method} ${path} ${payload}`) + return + } + execFileSync('gh', ['api', '-X', method, path, '--input', '-'], { + input: payload, + stdio: ['pipe', 'inherit', 'inherit'], + }) +} + +console.log(`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''}`) + +ghApiJson('PATCH', `repos/${repo}/branches/stage/protection/required_status_checks`, { + strict: false, + contexts: ['checks', 'migration-lock'], +}) + +ghApiJson('PATCH', `repos/${repo}/branches/main/protection/required_pull_request_reviews`, { + required_approving_review_count: 0, +}) + +console.log('[configure:branch-protection] done.') diff --git a/scripts/ensure-repo-deps.mjs b/scripts/ensure-repo-deps.mjs new file mode 100644 index 00000000..1cafdd00 --- /dev/null +++ b/scripts/ensure-repo-deps.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Idempotent bootstrap for worktrees/clones before hooks or gate scripts run. + * Installs deps when `node_modules` is missing/stale and wires Husky hooks. + * + * Used by `.husky/pre-push` and `pnpm push` — no imports from `scripts/lib/` + * (those need `node_modules` first). + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const runOrDie = (label, command, args) => { + console.log(`[ensure-deps] ${label}`) + const result = spawnSync(command, args, { cwd: repoRoot, stdio: 'inherit' }) + if ((result.status ?? 1) !== 0) { + console.error(`[ensure-deps] failed: ${command} ${args.join(' ')}`) + process.exit(result.status ?? 1) + } +} + +const needsInstall = () => { + const nodeModules = path.join(repoRoot, 'node_modules') + const lockfile = path.join(repoRoot, 'pnpm-lock.yaml') + if (!existsSync(nodeModules) || !existsSync(path.join(nodeModules, '.pnpm'))) return true + if (!existsSync(path.join(nodeModules, '.bin', 'prettier'))) return true + if (!existsSync(lockfile)) return false + return statSync(lockfile).mtimeMs > statSync(nodeModules).mtimeMs +} + +const huskyHooksMissing = () => { + try { + const hooksPath = execFileSync('git', ['config', 'core.hooksPath'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim() + return !hooksPath.includes('husky') + } catch { + return true + } +} + +export const ensureRepoDeps = () => { + if (needsInstall()) { + runOrDie('pnpm install --frozen-lockfile', 'pnpm', ['install', '--frozen-lockfile']) + } + if (huskyHooksMissing()) { + runOrDie('pnpm run prepare (husky)', 'pnpm', ['run', 'prepare']) + } +} + +if (process.argv[1]?.endsWith('ensure-repo-deps.mjs')) { + ensureRepoDeps() +} diff --git a/scripts/git-push.mjs b/scripts/git-push.mjs new file mode 100644 index 00000000..bb81e18a --- /dev/null +++ b/scripts/git-push.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Canonical push: bootstrap deps/hooks, run gate:push, then push. + * + * Does NOT rely on the Husky pre-push hook — `gate:push` runs here on purpose + * so worktrees/agents without wired hooks still get the ci-pr mirror (incl. + * format:check). Uses `--no-verify` only because the gate already ran; this is + * not an escape hatch (`git push --no-verify` without `pnpm push` still is). + * + * Humans who prefer `git push` after `pnpm i` still hit `.husky/pre-push`. + */ +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { ensureRepoDeps } from './ensure-repo-deps.mjs' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const runOrDie = (label, command, args) => { + console.log(`[pnpm push] ▶ ${label}`) + const result = spawnSync(command, args, { cwd: repoRoot, stdio: 'inherit' }) + if ((result.status ?? 1) !== 0) { + process.exit(result.status ?? 1) + } +} + +ensureRepoDeps() +runOrDie('gate:push', 'pnpm', ['gate:push']) + +const args = process.argv.slice(2) +const result = spawnSync('git', ['push', '--no-verify', ...args], { + cwd: repoRoot, + stdio: 'inherit', +}) +process.exit(result.status ?? 1) diff --git a/tests/int/homeSearchMunicipalities.int.spec.ts b/tests/int/homeSearchMunicipalities.int.spec.ts index f024cdea..77cd69b5 100644 --- a/tests/int/homeSearchMunicipalities.int.spec.ts +++ b/tests/int/homeSearchMunicipalities.int.spec.ts @@ -47,18 +47,21 @@ describe('searchHomeMunicipalities (B48)', () => { const advisor = await fixtures.createCampaignUser('advisor') const coordinator = await fixtures.createCampaignUser('coordinator') - const administered = await fixtures.getMunicipality('cairu') - const other = await fixtures.getMunicipality('feira-de-santana') + const administered = await fixtures.getMunicipality() + const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) - const advisorResult = await searchHomeMunicipalities(payload, advisor, 'Feira') + const otherQuery = other.name.split(/\s+/)[0]! + const administeredQuery = administered.name.split(/\s+/)[0]! + + const advisorResult = await searchHomeMunicipalities(payload, advisor, otherQuery) expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([]) - const coordinatorResult = await searchHomeMunicipalities(payload, coordinator, 'Feira') + const coordinatorResult = await searchHomeMunicipalities(payload, coordinator, otherQuery) expect(coordinatorResult.municipalities.some((hit) => hit.slug === other.slug)).toBe(true) - const advisorCairu = await searchHomeMunicipalities(payload, advisor, 'Cairu') - expect(advisorCairu.municipalities.map((hit) => hit.slug)).toEqual(['cairu']) + const advisorAdmin = await searchHomeMunicipalities(payload, advisor, administeredQuery) + expect(advisorAdmin.municipalities.map((hit) => hit.slug)).toEqual([administered.slug]) }) it('rejects leaders from home search', async () => { diff --git a/tests/int/searchStaffMunicipalityHits.int.spec.ts b/tests/int/searchStaffMunicipalityHits.int.spec.ts index ebf9c8a0..9e12e7dc 100644 --- a/tests/int/searchStaffMunicipalityHits.int.spec.ts +++ b/tests/int/searchStaffMunicipalityHits.int.spec.ts @@ -46,18 +46,21 @@ describe('searchStaffMunicipalityHits (B60)', () => { const advisor = await fixtures.createCampaignUser('advisor') const coordinator = await fixtures.createCampaignUser('coordinator') - const administered = await fixtures.getMunicipality('cairu') - const other = await fixtures.getMunicipality('feira-de-santana') + const administered = await fixtures.getMunicipality() + const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) - const advisorResult = await searchStaffMunicipalityHits(payload, advisor, 'Feira') + const otherQuery = other.name.split(/\s+/)[0]! + const administeredQuery = administered.name.split(/\s+/)[0]! + + const advisorResult = await searchStaffMunicipalityHits(payload, advisor, otherQuery) expect(advisorResult.map((hit) => hit.slug)).toEqual([]) - const coordinatorResult = await searchStaffMunicipalityHits(payload, coordinator, 'Feira') + const coordinatorResult = await searchStaffMunicipalityHits(payload, coordinator, otherQuery) expect(coordinatorResult.some((hit) => hit.slug === other.slug)).toBe(true) - const advisorCairu = await searchStaffMunicipalityHits(payload, advisor, 'Cairu') - expect(advisorCairu.map((hit) => hit.slug)).toEqual(['cairu']) + const advisorAdmin = await searchStaffMunicipalityHits(payload, advisor, administeredQuery) + expect(advisorAdmin.map((hit) => hit.slug)).toEqual([administered.slug]) }) it('rejects leaders from wizard municipality search', async () => {