mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(ops): auto-promote stage→main e merge em stage sem exigir head (#68)
* 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.
This commit is contained in:
parent
5ee9e840a2
commit
e04db48742
15 changed files with 320 additions and 64 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "<id> — <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
94
.github/workflows/promote-stage-to-main.yml
vendored
Normal file
94
.github/workflows/promote-stage-to-main.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
|
|
|
|||
53
scripts/configure-branch-protection.mjs
Normal file
53
scripts/configure-branch-protection.mjs
Normal file
|
|
@ -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.')
|
||||
57
scripts/ensure-repo-deps.mjs
Normal file
57
scripts/ensure-repo-deps.mjs
Normal file
|
|
@ -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()
|
||||
}
|
||||
36
scripts/git-push.mjs
Normal file
36
scripts/git-push.mjs
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue