Merge pull request #60 from fsolla/agent/ci-pr2-main-only
CI2 — main-only + gated deploy + pool 403
This commit is contained in:
commit
49f2b84782
61 changed files with 1567 additions and 918 deletions
|
|
@ -20,16 +20,35 @@ if [ ! -f .env ]; then
|
|||
echo "[cloud-setup] wrote .env from .env.example (local-only values)"
|
||||
fi
|
||||
|
||||
TEQO_URL='postgresql://teqo:teqo@localhost:5432/teqo'
|
||||
TEQO_TEST_URL='postgresql://teqo:teqo@localhost:5432/teqo_test'
|
||||
|
||||
assert_migrated() {
|
||||
local db_url="$1"
|
||||
local label="$2"
|
||||
local count
|
||||
count="$(PGPASSWORD=teqo psql "${db_url}" -Atc 'SELECT COUNT(*) FROM payload_migrations' 2>/dev/null || echo 0)"
|
||||
if [ "${count}" -lt 1 ]; then
|
||||
echo "[cloud-setup] ERROR: ${label} has no payload_migrations after migrate (got '${count}')" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[cloud-setup] ${label}: ${count} migrations applied"
|
||||
}
|
||||
|
||||
# Dev database (teqo) and test database (teqo_test).
|
||||
# Pin DATABASE_URL on both — do not rely on dotenv alone (JIT snapshots have
|
||||
# seen a silent no-op migrate when the URL was only in a freshly written .env).
|
||||
echo "[cloud-setup] migrating teqo..."
|
||||
pnpm migrate
|
||||
DATABASE_URL="${TEQO_URL}" pnpm migrate
|
||||
assert_migrated "${TEQO_URL}" teqo
|
||||
echo "[cloud-setup] migrating teqo_test..."
|
||||
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate
|
||||
DATABASE_URL="${TEQO_TEST_URL}" pnpm migrate
|
||||
assert_migrated "${TEQO_TEST_URL}" teqo_test
|
||||
|
||||
echo "[cloud-setup] seeding teqo..."
|
||||
pnpm db:seed:minimal
|
||||
DATABASE_URL="${TEQO_URL}" pnpm db:seed:minimal
|
||||
echo "[cloud-setup] seeding teqo_test..."
|
||||
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm db:seed:minimal
|
||||
DATABASE_URL="${TEQO_TEST_URL}" pnpm db:seed:minimal
|
||||
|
||||
echo "[cloud-setup] OK — minimal database ready (dev + test)."
|
||||
echo "[cloud-setup] gate:fast/gate:push need no DB; use pnpm push (not raw git push --no-verify)."
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Cursor Cloud VMs have no Docker and cannot run docker compose (overlayfs fails inside
|
||||
# the agent container). Native PostgreSQL matches the local DATABASE_URL contract.
|
||||
#
|
||||
# Important: "server accepting connections" ≠ "teqo role/DBs exist". A warm snapshot
|
||||
# often has the cluster online with no app databases yet. Never gate cluster start on
|
||||
# teqo readiness — bootstrap runs after the server is up.
|
||||
set -euo pipefail
|
||||
|
||||
PG_VERSION="${PG_VERSION:-16}"
|
||||
PG_CLUSTER="${PG_CLUSTER:-main}"
|
||||
# pg_ctlcluster / systemctl can report "started" before the postmaster accepts TCP.
|
||||
READY_ATTEMPTS="${PG_READY_ATTEMPTS:-15}"
|
||||
READY_SLEEP_SECS="${PG_READY_SLEEP_SECS:-1}"
|
||||
|
||||
pg_ready() {
|
||||
teqo_ready() {
|
||||
PGPASSWORD=teqo psql -h localhost -U teqo -d teqo -c 'SELECT 1' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
server_ready() {
|
||||
pg_isready -h localhost -p 5432 >/dev/null 2>&1 \
|
||||
|| sudo -u postgres psql -c 'SELECT 1' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_pg_version() {
|
||||
if [ -d "/etc/postgresql" ]; then
|
||||
ls /etc/postgresql/ 2>/dev/null | sort -nr | head -1
|
||||
|
|
@ -18,8 +30,19 @@ detect_pg_version() {
|
|||
echo "${PG_VERSION}"
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
local attempt
|
||||
for attempt in $(seq 1 "${READY_ATTEMPTS}"); do
|
||||
if server_ready; then
|
||||
return 0
|
||||
fi
|
||||
sleep "${READY_SLEEP_SECS}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start_postgres() {
|
||||
if pg_ready; then
|
||||
if server_ready; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
@ -28,15 +51,17 @@ start_postgres() {
|
|||
|
||||
if command -v pg_ctlcluster >/dev/null 2>&1 && [ -n "${version}" ]; then
|
||||
sudo pg_ctlcluster "${version}" "${PG_CLUSTER}" start 2>/dev/null || true
|
||||
sleep 2
|
||||
pg_ready && return 0
|
||||
if wait_for_server; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl start "postgresql@${version}-${PG_CLUSTER}" 2>/dev/null || \
|
||||
sudo systemctl start postgresql 2>/dev/null || true
|
||||
sleep 2
|
||||
pg_ready && return 0
|
||||
if wait_for_server; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[ensure-postgres] ERROR: postgres is installed but not accepting connections" >&2
|
||||
|
|
@ -59,10 +84,17 @@ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'teqo_test')\gexec
|
|||
SQL
|
||||
}
|
||||
|
||||
if command -v psql >/dev/null 2>&1 && [ -d "/etc/postgresql" ]; then
|
||||
ensure_ready() {
|
||||
start_postgres
|
||||
bootstrap_databases
|
||||
pg_ready
|
||||
if ! teqo_ready; then
|
||||
echo "[ensure-postgres] ERROR: teqo databases exist but connection as teqo/teqo failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v psql >/dev/null 2>&1 && [ -d "/etc/postgresql" ]; then
|
||||
ensure_ready
|
||||
echo "[ensure-postgres] OK (existing cluster)"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -70,7 +102,5 @@ fi
|
|||
echo "[ensure-postgres] installing postgresql-${PG_VERSION} (Ubuntu package; local dev uses PG 17 via Docker)..."
|
||||
sudo apt-get update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}"
|
||||
start_postgres
|
||||
bootstrap_databases
|
||||
pg_ready
|
||||
ensure_ready
|
||||
echo "[ensure-postgres] OK"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
---
|
||||
description: PR workflow for Teqo agents — overrides user-level gh-github draft defaults
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent PR workflow (Teqo)
|
||||
|
||||
**This repo overrides any user-level rule that says "always create PRs as draft".** Agent deliveries merge to `stage`, not `main`.
|
||||
**This repo overrides any user-level rule that says "always create PRs as draft".** Agent deliveries merge to `main`.
|
||||
|
||||
## Create (Ready, base stage)
|
||||
## Create (Ready, base main)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD
|
||||
gh pr create --base stage --title "<id> — <title>" --body "$(cat <<'EOF'
|
||||
gh pr create --base main --title "<id> — <title>" --body "$(cat <<'EOF'
|
||||
## O que é
|
||||
<2–4 linhas>
|
||||
|
||||
|
|
@ -21,7 +16,7 @@ EOF
|
|||
```
|
||||
|
||||
- **Never** `--draft`. PRs must be **Ready for review** on creation.
|
||||
- Base is always **`stage`** (never `main` — promote é automático via `promote-stage-to-main.yml` após CI stage green).
|
||||
- Base is always **`main`**.
|
||||
|
||||
## Push (determinístico)
|
||||
|
||||
|
|
@ -31,12 +26,10 @@ EOF
|
|||
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
|
||||
pnpm push -u origin HEAD # canonical
|
||||
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
|
||||
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
|
||||
```
|
||||
|
||||
## Auto-merge immediately
|
||||
|
|
@ -51,12 +44,12 @@ Do not wait for human approval. Auto-merge fires when **required** checks pass.
|
|||
|
||||
## Required checks vs Vercel
|
||||
|
||||
Branch protection on `stage` requires only:
|
||||
Branch protection on `main` requires only:
|
||||
|
||||
- `checks` (GitHub Actions rollup from `ci-pr.yml`)
|
||||
- `migration-lock`
|
||||
|
||||
**Vercel preview is NOT a merge gate.** Do not block on it.
|
||||
**Vercel preview is NOT a merge gate.** Production deploy is Actions-gated (`ci.yml` → `vercel deploy --prod` after full suite green). Do not block on Vercel Git status.
|
||||
|
||||
When watching CI:
|
||||
|
||||
|
|
@ -64,14 +57,12 @@ When watching CI:
|
|||
gh pr checks <PR> --watch --required
|
||||
```
|
||||
|
||||
Use `--required` — without it, `--watch` also waits for Vercel and can stall for minutes on the Hobby plan (one concurrent build).
|
||||
|
||||
Once `checks` and `migration-lock` are green, auto-merge proceeds even if Vercel is still `PENDING`.
|
||||
Use `--required` — without it, `--watch` also waits for Vercel.
|
||||
|
||||
## Agent branches skip Vercel builds
|
||||
|
||||
Branches `agent/*` and `cursor/*` use `scripts/vercel-ignore-build.sh` so parallel agents do not queue Vercel builds. Previews still deploy for `stage` and `main`.
|
||||
All branches skip Vercel Git builds via `scripts/vercel-ignore-build.sh` (deploys only from the main verifier Action).
|
||||
|
||||
## Promote (human only)
|
||||
## Promote
|
||||
|
||||
Never run `gh pr create --base main` or `pnpm agent:promote` in the agent flow.
|
||||
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies and deploys.
|
||||
|
|
|
|||
|
|
@ -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). **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`).
|
||||
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). Entrega = merge em `main`; deploy gated pelo verificador `ci.yml`. **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,7 +7,7 @@ description: Opera o pool de agentes Cursor Cloud do Teqo (supervisor remoto sta
|
|||
|
||||
O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 8) rodando `work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é o workflow **`.github/workflows/agent-pool.yml`** (tick stateless a cada 10 min + a cada merge em `stage` + sob dispatch) rodando `scripts/agent-pool.mjs` — nenhum processo local, laptop fechado não importa. Arquitetura, elegibilidade (`isAutonomousClaimable`), anti-race (claim coordenado pelo supervisor) e ciclo de vida: `docs/plans/agent-pool-orchestrator.md`.
|
||||
|
||||
**O pool NUNCA faz `pnpm agent:promote`** — promote `stage→main` continua humano.
|
||||
**O pool NUNCA deploya** — deploy gated fica em `ci.yml` após merge em `main`.
|
||||
|
||||
## Pré-requisitos (uma vez)
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe c
|
|||
|
||||
## Ciclo de vida de uma Issue no pool
|
||||
|
||||
`ready` → (tick: claim coordenado, flip + marcador) → `in-progress` → worker spawnado com o `model:` da Issue (fallback `composer-2.5`) → PR `--base stage` com `Closes #N` → CI green → auto-merge → `done` (flip determinístico do CI) → tick arquiva o agente e repõe o slot.
|
||||
`ready` → (tick: claim coordenado, flip + marcador) → `in-progress` → worker spawnado com o `model:` da Issue (fallback `composer-2.5`) → PR `--base main` com `Closes #N` → CI green → auto-merge → `done`+`in-prod` (flip determinístico do CI) → tick arquiva o agente e repõe o slot.
|
||||
|
||||
- **Falha terminal** (run ERROR/CANCELLED/EXPIRED ou fim sem PR): tick comenta, move a Issue para `blocked` e arquiva o agente. **Triage humana**: ler o run em cursor.com/agents, decidir — re-`ready` manual se transitório (o circuit breaker recusa a 3ª tentativa automática), corrigir a spec se sistêmico.
|
||||
- **Worker travado**: archive em cursor.com/agents → o próximo tick reconcilia como falha documentada.
|
||||
|
|
@ -41,12 +41,13 @@ Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe c
|
|||
|
||||
## Smoke remoto (aceite — rodar na primeira ativação)
|
||||
|
||||
1. 2 Issues `kind:chore` triviais, `ready`, sem `model:` → `start --max-slots 2` → 2 agents `pool-i<N>-…` em cursor.com/agents e as 2 Issues `in-progress` com comentário de claim. Zero duplicata.
|
||||
1. 2 Issues `kind:chore` triviais, `ready`, sem `model:` → `start --max-slots 2` → 2 agents `pool-i<N>-…` em cursor.com/agents e as 2 Issues `in-progress` com comentário de claim. Zero duplicata. Modelo efetivo = `composer-2.5`.
|
||||
2. 1 Issue `blocked` e 1 `needs:consent` plantadas na fila → permanecem intactas.
|
||||
3. 1 Issue com `model: cursor-grok-4.5-high` → run criado com esse modelo (doctor lista os ids válidos).
|
||||
4. Workers abrem PRs `--base stage`; merge → slot reposto automaticamente no tick seguinte (≤10 min).
|
||||
5. Fila drenada → `POOL_ENABLED=false` sozinho. `stop` → próximo tick sem spawns.
|
||||
6. Tudo com o laptop desligado (start pelo browser/celular).
|
||||
4. (Opcional) par bipartido: Issue plan (`cursor-grok-4.5-high`) + Issue exec (`kimi-k3-low`, `depends` no plan) — o tick só spawna a exec quando o plan estiver `done`.
|
||||
5. Workers abrem PRs na base canônica do pool; merge → slot reposto automaticamente no tick seguinte (≤10 min).
|
||||
6. Fila drenada → `POOL_ENABLED=false` sozinho. `stop` → próximo tick sem spawns.
|
||||
7. Tudo com o laptop desligado (start pelo browser/celular).
|
||||
|
||||
## Se algo quebrar
|
||||
|
||||
|
|
|
|||
|
|
@ -3,67 +3,66 @@ name: model-selection
|
|||
description: >-
|
||||
Escolhe o modelo (e o nível de raciocínio/effort) com melhor custo/valor
|
||||
para cada classe de tarefa do Teqo, e despacha subagentes já no modelo certo
|
||||
via o parâmetro `model` da tool Task. Usar no início de trabalho de
|
||||
implementação, ao despachar subagentes, antes de passes de /simplify ou
|
||||
auditoria, ou quando o usuário perguntar "qual modelo usar", "modelo mais
|
||||
barato para X", "troca o modelo", "vale a pena effort high".
|
||||
via o parâmetro `model` da tool Task. Caminho feliz único: Composer 2.5 ↔
|
||||
Grok 4.5 (effort) ↔ Kimi K3 Low (exec bipartida). Usar no início de
|
||||
implementação, ao despachar subagentes, antes de /simplify, ou quando o
|
||||
usuário perguntar "qual modelo usar".
|
||||
---
|
||||
|
||||
# Seleção de modelo × effort por classe de tarefa
|
||||
# Seleção de modelo × effort
|
||||
|
||||
Dois eixos independentes, decididos nesta ordem: **(1) classe da tarefa → modelo**, **(2) custo de uma falha → effort**. Preço e contexto compram capacidade; effort compra deliberação (tokens de thinking, cobrados como output). Esforço escala com **quanto custa errar**, não com o tamanho da tarefa.
|
||||
Dois eixos, nesta ordem: **(1) classe da tarefa → modelo**, **(2) custo de uma falha → effort**. Preço e contexto compram capacidade; effort compra deliberação.
|
||||
|
||||
## Tabela canônica (atualizada 2026-07-29 — revalidar preços em cursor.com/docs/models-and-pricing antes de citar valores)
|
||||
## Tabela canônica (2026-07-31)
|
||||
|
||||
| Classe de tarefa | Modelo (1ª escolha) | Fallback metered | Effort |
|
||||
| ----------------------------------------------------------------------------------------------------- | ------------------- | ---------------- | -------- |
|
||||
| Feature delivery do roadmap (itens B/E/C, multi-arquivo, gates completos) | Composer 2.5 (pool) | Gemini 3.1 Pro | Medium |
|
||||
| Passes de /simplify, auditoria de plano, review debts (bugs sutis: closures, races, drift de strings) | Kimi K3 (1M ctx) | Gemini 3.1 Pro | **High** |
|
||||
| Migrations, access control / RBAC, Consent, segurança (blast radius alto, história congelada) | Kimi K3 | Gemini 3.1 Pro | **High** |
|
||||
| Fixes pequenos e localizados (um arquivo, sintoma conhecido, repair de teste) | Kimi K2.7 | Haiku 4.5 | Medium |
|
||||
| Ciclos de discovery / research (compêndios, personas, relatórios longos em pt-BR — write-heavy) | GLM 5.2 | Gemini 3.6 Flash | Medium |
|
||||
| Docs/bookkeeping (planos, TECH-DEBT, débitos no backlog de Issues), Prettier, merges, rebase, chores | Grok 4.5 (pool) | Kimi K2.7 | **Low** |
|
||||
| Perf/infra (medição de bundle, waterfall, análise de queries) | Gemini 3.1 Pro | Composer 2.5 | Medium |
|
||||
| Depuração travada ("horas sem progresso", bug intermitente com hipóteses esgotadas) | Kimi K3 | GPT-5.6 Luna | **Max** |
|
||||
| Prioridade | Modelo | Quando |
|
||||
| ---------- | ------ | ------ |
|
||||
| 1 (default) | **Composer 2.5** (`composer-2.5`, sem `-fast`) | Features, chores simples, fixes localizados, docs leves — sempre que couber |
|
||||
| 2 | **Grok 4.5** (`cursor-grok-4.5-*`) | Effort por complexidade (abaixo) |
|
||||
| 3 | **Kimi K3 Low** (`kimi-k3-low`) | Só na **fase de execução** de issues bipartidas (refactor grande / simplify / migrations+RBAC de blast radius alto), depois de um plano fechado |
|
||||
|
||||
**Removidos** desta skill (não usar como 1ª escolha nem fallback documentado): Kimi K2.7, Haiku, GLM, Gemini, Luna. Se a API Cloud rejeitar um slug, o pool já faz fallback para `composer-2.5` no código — não documentar outros modelos aqui.
|
||||
|
||||
### Effort (Grok 4.5)
|
||||
|
||||
| Effort | Uso |
|
||||
| ------ | --- |
|
||||
| Low | Bookkeeping, Prettier, merges, rebase, chores mecânicos |
|
||||
| Medium | Discovery / análise moderada |
|
||||
| **High** | **Default para issues complexas** (multi-domínio, design não óbvio) |
|
||||
|
||||
Slug Task de referência: `cursor-grok-4.5-high` (High). Para Low/Medium na sessão principal, use o seletor de modelo/effort do produto — o pool resolve o id da Issue via `resolvePoolModel`.
|
||||
|
||||
## Issues muito complexas (bipartir)
|
||||
|
||||
Quando o blast radius é alto (refactor amplo, simplify repo-wide, migration+RBAC), `plan-issue` **registra duas Issues encadeadas**:
|
||||
|
||||
1. `{id}-plan` — `model: cursor-grok-4.5-high` — entregável = plano em `docs/plans/…` + critérios de aceite da execução
|
||||
2. `{id}` ou `{id}-exec` — `model: kimi-k3-low`, `depends: [{id}-plan]` — executa o plano
|
||||
|
||||
Não despachar Kimi K3 Low sem plano fechado na Issue de plan.
|
||||
|
||||
## Regras
|
||||
|
||||
1. **Pool primeiro.** Composer 2.5 e Grok 4.5 saem do pool "Cursor Models" (uso incluído generoso) — ~70% do trabalho deste repo é rotina e não deveria tocar o pool metered ("Other Models", cobrado a preço de API).
|
||||
2. **Kimi K3 é cirúrgico, não default.** Custa 3× o K2.7 no input e ~4× no output. Reservar para as três classes onde uma falha perdida vira bug de produção ou migration congelada errada. O 1M de contexto sem sobretaxa é o diferencial para auditorias repo-wide.
|
||||
3. **Effort default = Medium.** Subir para High só nas classes marcadas; Low em docs/formatação/merges; Max é exceção de depuração, nunca rotina. Em tarefa fácil, effort alto só reescreve o óbvio com tokens mais caros.
|
||||
4. **Nunca economizar em:** migrations (história congelada, aplicam em produção no build), access control (`overrideAccess: false`), Consent/LGPD, e passes de /simplify — o histórico do repo mostra que é neles que os P0/P1 sutis aparecem.
|
||||
5. **Metered barato ≠ grátis:** K2.7 ($0.95/$4) e Haiku 4.5 ($1/$5) consomem o pool "Other Models"; se a tarefa cabe num pool model, pool vence sempre.
|
||||
1. **Composer 2.5 é o default.** Se a tarefa cabe nele, não suba de modelo.
|
||||
2. **Grok High para complexidade**, não para volume mecânico.
|
||||
3. **Kimi K3 Low só na exec bipartida** (ou quando a Issue já declara `kimi-k3-low` após um plan).
|
||||
4. **Nunca economizar em:** migrations (história congelada), access control (`overrideAccess: false`), Consent/LGPD — se for uma Issue só disso e complexa, bipartir (plan Grok High → exec K3).
|
||||
5. **Usuário pediu modelo específico:** a escolha dele vence; avise uma vez se houver desperdício claro.
|
||||
|
||||
## Como aplicar o modelo
|
||||
## Como aplicar
|
||||
|
||||
- **Subagentes (automático):** a tool `Task` aceita `model`. Despache já no slug certo — não rode o subagente no modelo herdado e sugira troca depois:
|
||||
- `composer-2.5` / `composer-2.5-fast` — features e rotina
|
||||
- `cursor-grok-4.5-high` — docs, chores, exploração
|
||||
- `gemini-3.1-pro` — fallback metered forte / perf
|
||||
- `gemini-3.6-flash-high` — discovery write-heavy (fallback)
|
||||
- `glm-5.2-high` — discovery/research
|
||||
- `kimi-k2.7-code` — fixes pequenos
|
||||
- `kimi-k3-low` — auditorias/simplify/migrations (única variante K3 disponível para subagentes; o raciocínio extra vem do modelo, não do effort knob aqui)
|
||||
- `claude-4.5-haiku-thinking` / `gpt-5.6-luna-medium` — fallbacks de fix pequeno
|
||||
- `inherit` só quando a classe do subagente é a mesma da sessão.
|
||||
- **Sessão principal (sugestão):** o agente não troca o próprio modelo. Ao classificar a tarefa no início do trabalho, se o modelo atual não for o da tabela, diga em uma linha: _"Esta tarefa é classe X — o melhor custo/valor é **Y** (effort Z); considere trocar no seletor de modelo."_ Não repita o aviso na mesma sessão.
|
||||
- **Usuário pediu modelo específico:** a escolha dele vence a tabela; use-a só para avisar uma vez se houver desperdício claro (ex.: K3 numa formatação Prettier).
|
||||
- **Subagentes (`Task.model`):** só estes slugs nesta skill:
|
||||
- `composer-2.5`
|
||||
- `cursor-grok-4.5-high`
|
||||
- `kimi-k3-low`
|
||||
- `inherit` só quando a classe do subagente é a mesma da sessão
|
||||
- **Sessão principal:** o agente não troca o próprio modelo. Se o atual não for o da tabela, diga em uma linha e siga.
|
||||
- **Pool:** `POOL_DEFAULT_MODEL_SLUG` = `composer-2.5`; `resolvePoolModel` cai para Composer se o slug da Issue for desconhecido.
|
||||
|
||||
## Gates por fluxo (onde a decisão de modelo entra no loop)
|
||||
## Gates por fluxo
|
||||
|
||||
- **`/simplify` (comando built-in, não editável):** seus três subagentes de review **herdam o modelo da sessão pai** — não há como despachá-los em outro modelo. Portanto a decisão acontece **antes** de invocar `/simplify`: se a sessão não estiver em Kimi K3 (effort High) ou Gemini 3.1 Pro, avise uma vez e sugira a troca — _"passes de /simplify são onde este repo acha os P0/P1 sutis; considere rodar em K3 High."_ Se o usuário optar por seguir no modelo barato, aceite — é escolha informada, não erro.
|
||||
- **`work-issue`:** a propriedade `model:` da Issue é **verificada, não recalculada** (regra assimétrica: sessão mais fraca → informa e segue; mais forte → informa e pausa). A classe pode mudar **por fase** — fases de schema/migration são High-effort (K3), fases de documentação da sessão são Low (pool model). Sessões longas: vale sugerir troca entre a fase de engenharia e a fase final de docs.
|
||||
- **`plan-issue`:** a sugestão de modelo × effort por item (esta tabela) vira a propriedade `model:` da Issue registrada via `agent:register --model`.
|
||||
- **`project-status`:** leitura + apresentação — classe docs/análise; pool model (Grok 4.5) ou metered barato bastam. O `model:` que ele exibe por item é para a **worktree de implementação**, não para a sessão de status.
|
||||
- **Discovery cycles (`docs/research/`):** write-heavy pt-BR — GLM 5.2 ou Gemini 3.6 Flash, Medium.
|
||||
|
||||
## Quando o effort faz diferença (critério de decisão)
|
||||
|
||||
Suba um degrau de effort quando **duas ou mais** forem verdade:
|
||||
|
||||
- a falha é silenciosa (sem teste que a pegue — ex.: drift de string em `safeMessages`, race otimista);
|
||||
- a verificação exige rastrear um valor por 3+ frames/requests/renders;
|
||||
- o diff toca migration, access, ou hook com `req`/transação;
|
||||
- a tarefa é "provar ausência" (auditoria, knip, ciclos) e não "produzir presença".
|
||||
|
||||
Desça um degrau quando a saída é mecânica (formatar, renomear, registrar débito, resolver merge trivial) ou quando já existe gate que pega o erro (tsc/lint/teste pinado cobre exatamente o risco).
|
||||
- **`/simplify`:** herda o modelo da sessão. Preferir sessão em Kimi K3 Low **só** quando houver Issue de exec bipartida; caso contrário Composer ou Grok High conforme complexidade.
|
||||
- **`work-issue`:** verifica `model:` da Issue (assimétrico: sessão mais fraca → informa e segue; mais forte → informa e pausa). Ausente → aplica esta tabela.
|
||||
- **`plan-issue`:** sugere via esta tabela; em issues muito complexas, registra o par plan/exec.
|
||||
- **`agent-pool` smoke:** Composer default; Grok High / Kimi K3 Low em Issues de teste bipartidas.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,13 @@ Ordem topológica intra-lote (o plano do dependente cita o ID do dependido, que
|
|||
|
||||
## Passo 4 — Sugestão de modelo × effort
|
||||
|
||||
Aplique a skill `model-selection` (tabela canônica) por item e registre a sugestão no cabeçalho do plano (`Model:`) e no resumo do gate. Referência rápida: `kimi-k3-low` para migration/RBAC/escala-dry; `composer-2.5` para feature UI; `cursor-grok-4.5-high` para docs/chore. O valor vai ao frontmatter `model:` da Issue — é **propriedade da Issue**, verificada (não recalculada) na execução por `work-issue`.
|
||||
Aplique a skill `model-selection` (tabela canônica) por item e registre a sugestão no cabeçalho do plano (`Model:`) e no resumo do gate. Caminho feliz único:
|
||||
|
||||
- `composer-2.5` — default (features, chores simples, fixes localizados, docs leves)
|
||||
- `cursor-grok-4.5-high` — issues complexas / discovery (effort High); Low/Medium via seletor para bookkeeping
|
||||
- `kimi-k3-low` — **só** fase de execução de issues bipartidas (refactor / simplify / migration+RBAC de blast radius alto)
|
||||
|
||||
**Issues muito complexas:** registre **duas** Issues encadeadas — `{id}-plan` (`model: cursor-grok-4.5-high`, entregável = plano + critérios de aceite) e `{id}`/`{id}-exec` (`model: kimi-k3-low`, `depends: [{id}-plan]`). O valor vai ao frontmatter `model:` — propriedade da Issue, verificada (não recalculada) por `work-issue`.
|
||||
|
||||
## Passo 5 — GATE de confirmação (obrigatório)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Status: rascunho
|
|||
Atualizado em: <YYYY-MM-DD>
|
||||
Issue: #<N> (preencher após `pnpm agent:register`; enquanto não registrado: "—")
|
||||
Priority: <P0 | P1 | P2 | P3>
|
||||
Model: <slug sugerido por model-selection, ex.: composer-2.5>
|
||||
Model: <slug sugerido por model-selection: composer-2.5 | cursor-grok-4.5-high | kimi-k3-low>
|
||||
Impeccable: <A | B | C | D> — <uma linha: N/A sem UI | encaixe em tela X | UI nova em rota Y | ref Nome.png>
|
||||
Appetite: <ex.: ~1–2 dias eng; migration + 1 action + encaixe em lista existente>
|
||||
Responsável: —
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
---
|
||||
name: work-issue
|
||||
description: Conduz uma GitHub Issue rastreável do Teqo do claim ao merge em stage — prep (pnpm i), claim (pnpm agent:claim), renomear a sessão + abrir o plano no editor, verificação do modelo declarado na Issue, freshness audit do plano, execução das fases (schema/server → UI via /impeccable → gates), /simplify (3 reviewers paralelos via Task) + capture-review-debts, PR --base stage com Closes #N, acompanhamento do CI até o merge. Usar quando o usuário pedir para implementar/trabalhar uma Issue ("trabalha a issue #N", "implementa o B79", "pega a próxima issue", "vamos fazer o X", "continua a implementação").
|
||||
description: Conduz uma GitHub Issue rastreável do Teqo do claim ao merge em main — prep (pnpm i), claim (pnpm agent:claim), renomear a sessão + abrir o plano no editor, verificação do modelo declarado na Issue, freshness audit do plano, execução das fases (schema/server → UI via /impeccable → gates), /simplify (3 reviewers paralelos via Task) + capture-review-debts, PR --base main com Closes #N, acompanhamento do CI até o merge. Usar quando o usuário pedir para implementar/trabalhar uma Issue ("trabalha a issue #N", "implementa o B79", "pega a próxima issue", "vamos fazer o X", "continua a implementação").
|
||||
---
|
||||
|
||||
# Trabalhar uma Issue (claim → merge em stage)
|
||||
# Trabalhar uma Issue (claim → merge em main)
|
||||
|
||||
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).
|
||||
Esta skill conduz UMA Issue rastreável do claim ao merge em `main`. 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:** 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.
|
||||
**Proibido neste fluxo:** qualquer acesso a DB de prod, merge sem CI green, editar outras Issues `in-progress`.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -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: branch → commit → **`pnpm push -u origin HEAD`** (não `git push` nu) → gh pr create --base stage (Closes #N, sem --draft)
|
||||
- [ ] 6. Fechar em main: branch → commit → **`pnpm push -u origin HEAD`** (não `git push` nu) → gh pr create --base main (Closes #N, sem --draft)
|
||||
→ gh pr merge --auto --merge → gh pr checks --watch --required até o merge → consolidar pontas soltas
|
||||
```
|
||||
|
||||
|
|
@ -92,13 +92,14 @@ Se `open_resource` não estiver disponível (fora da Agents Window), diga o cami
|
|||
|
||||
## Passo 2 — Verificação de modelo (best effort, não programática)
|
||||
|
||||
Leia a propriedade `model:` do brief e compare com o modelo da sessão atual pela tabela de capacidade de `model-selection`. Regra **assimétrica** (decisão travada 2026-07-30):
|
||||
Leia a propriedade `model:` do brief e compare com o modelo da sessão atual pela tabela de capacidade de `model-selection` (Composer 2.5 ↔ Grok 4.5 ↔ Kimi K3 Low). Regra **assimétrica** (decisão travada 2026-07-30):
|
||||
|
||||
- Sessão **mais fraca** que o especificado → assume-se escolha consciente do humano: **informa em uma linha e continua, sem pausar**.
|
||||
- Sessão **mais forte** que o especificado → possível erro do humano: **informa e pausa** ("a Issue pede X, você está em Y — seguir mesmo assim?").
|
||||
- Propriedade **ausente** → aplique `model-selection` uma vez e registre a escolha na Issue (`gh issue edit <N>` no body, frontmatter `model:`).
|
||||
- Issue `{id}-exec` / `kimi-k3-low` sem a dep de plan `done` → **pare** (não execute sem plano fechado).
|
||||
|
||||
Subagentes despachados via `Task` saem **no modelo da propriedade** quando couber (`Task.model`).
|
||||
Subagentes despachados via `Task` saem **no modelo da propriedade** quando couber (`Task.model` ∈ `composer-2.5` | `cursor-grok-4.5-high` | `kimi-k3-low`).
|
||||
|
||||
## Passo 3 — Freshness audit (enxuto)
|
||||
|
||||
|
|
@ -144,15 +145,15 @@ Lance **três** subagentes via `Task` **em paralelo** na mesma mensagem — read
|
|||
2. Achados maiores que o cleanup da sessão → skill `capture-review-debts` (registra via `agent:register`/`agent:file-miss`; **nunca** edita Issue `in-progress` — nem esta; débito do mesmo pai vira Issue nova com `depends: [<id-do-pai>]`).
|
||||
3. Resuma o que corrigiu e o que ficou como recomendação/débito.
|
||||
|
||||
## Passo 6 — Fechar em stage
|
||||
## Passo 6 — Fechar em main
|
||||
|
||||
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 (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.
|
||||
3. `gh pr create --base main` 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 (`strict=false` na proteção de `main`).
|
||||
5. **Acompanhe só os checks obrigatórios** (`gh pr checks <PR> --watch --required`): gate = `checks` + `migration-lock`; Vercel Git não bloqueia. Falha no ci-pr → corrige na mesma branch. **Conflito de merge** → rebase em `main` e reempurre.
|
||||
6. O flip `in-progress → done` + `in-prod` é **determinístico no CI** (`issue-done-on-main-merge.yml`). Deploy gated fica em `ci.yml` — não é passo do agente. Consolide pontas soltas após o merge.
|
||||
|
||||
## 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 (`pnpm push`, sem `--no-verify` cru), 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.
|
||||
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 (`pnpm push`, sem `--no-verify` cru), link do PR e estado do merge em main, pontas soltas consolidadas. `done`+`in-prod` no merge; deploy gated em `ci.yml`.
|
||||
|
|
|
|||
20
.github/workflows/agent-pool.yml
vendored
20
.github/workflows/agent-pool.yml
vendored
|
|
@ -4,12 +4,13 @@
|
|||
# POOL_MAX_SLOTS over `ready` autonomous-eligible issues. Three triggers:
|
||||
# - workflow_dispatch — the canonical human channel (start/stop/pause/resume)
|
||||
# - schedule */10min — the periodic reconcile (default branch only)
|
||||
# - pull_request closed on stage — immediate slot replenish after a merge
|
||||
# Schedule and PR triggers only run from the default branch: they activate
|
||||
# after the human promote to main; until then dispatch on the branch works.
|
||||
# - pull_request closed on main — immediate slot replenish after a merge
|
||||
#
|
||||
# Secrets: CURSOR_API_KEY (repo secret) — Cursor Cloud Agents API key.
|
||||
# GITHUB_TOKEN needs actions: write (pool state lives in repo variables).
|
||||
# Secrets:
|
||||
# CURSOR_API_KEY — Cursor Cloud Agents API key
|
||||
# POOL_GITHUB_TOKEN — PAT with actions:write + issues:write (required to read/
|
||||
# write repo Actions variables; default GITHUB_TOKEN often 403s — mirror of
|
||||
# the old PROMOTE_GITHUB_TOKEN pattern)
|
||||
name: Agent pool supervisor
|
||||
|
||||
on:
|
||||
|
|
@ -17,7 +18,7 @@ on:
|
|||
- cron: '*/10 * * * *'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [stage]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
|
|
@ -36,7 +37,6 @@ on:
|
|||
required: false
|
||||
type: string
|
||||
|
||||
# Single-allocator invariant: ticks never overlap.
|
||||
concurrency:
|
||||
group: agent-pool
|
||||
cancel-in-progress: false
|
||||
|
|
@ -51,7 +51,6 @@ jobs:
|
|||
tick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# pull_request closed WITHOUT merge is not a replenish signal — skip.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -60,17 +59,14 @@ jobs:
|
|||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
# The pool scripts import scripts/lib/cli.mjs (dotenv) via agent-github —
|
||||
# they are NOT dependency-free, so install like every sibling workflow.
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: agent:pool workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.POOL_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
ACTION: ${{ inputs.action }}
|
||||
MAX_SLOTS: ${{ inputs.maxSlots }}
|
||||
run: |
|
||||
# schedule / pull_request merged → reconcile tick; dispatch → the chosen action.
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
args=(--action "$ACTION" --actor "$GITHUB_ACTOR")
|
||||
if [ -n "$MAX_SLOTS" ]; then args+=(--max-slots "$MAX_SLOTS"); fi
|
||||
|
|
|
|||
239
.github/workflows/ci-pr.yml
vendored
239
.github/workflows/ci-pr.yml
vendored
|
|
@ -1,19 +1,18 @@
|
|||
# PR gate: feature → stage (and stage → main promote PRs).
|
||||
# Parallel jobs with `needs` only where a real dependency exists (Postgres for
|
||||
# int/build/e2e). Build runs here (local service DB only) and NEVER in
|
||||
# ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL it
|
||||
# sees, so it is forbidden anywhere near stage/prod URLs.
|
||||
# PR gate: feature → main. Two-phase cascade:
|
||||
# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit
|
||||
# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A
|
||||
# phase-1 failure skips phase 2 via `needs` + explicit if. `migration-lock`
|
||||
# stays parallel to phase 1 (cheap, independent).
|
||||
#
|
||||
# Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF`
|
||||
# and e2e runs the manifest-selected specs; a diff touching a high-risk path
|
||||
# (schema, lockfile, test harness, seed manifest — see
|
||||
# scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to
|
||||
# stage/main always runs everything (ci-stage.yml / ci.yml).
|
||||
# High-risk diffs (schema, lockfile, classifiers — see
|
||||
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
|
||||
# always runs the full suite in ci.yml (no affected skips).
|
||||
name: CI (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [stage, main]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.event.pull_request.number }}
|
||||
|
|
@ -25,6 +24,38 @@ env:
|
|||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
code_mode: ${{ steps.classify.outputs.code_mode }}
|
||||
build_mode: ${{ steps.classify.outputs.build_mode }}
|
||||
test_mode: ${{ steps.classify.outputs.test_mode }}
|
||||
e2e_mode: ${{ steps.classify.outputs.e2e_mode }}
|
||||
e2e_specs: ${{ steps.classify.outputs.e2e_specs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Classify PR blast radius
|
||||
id: classify
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/ci-scope.mjs)
|
||||
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
|
||||
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
|
@ -36,6 +67,8 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- run: pnpm lint
|
||||
|
||||
format:
|
||||
|
|
@ -54,6 +87,8 @@ jobs:
|
|||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -67,6 +102,8 @@ jobs:
|
|||
knip:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -80,6 +117,8 @@ jobs:
|
|||
cycles:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -93,6 +132,8 @@ jobs:
|
|||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.test_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -104,33 +145,30 @@ jobs:
|
|||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Compute affected test scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/test-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Unit tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Unit tests (changed since PR base)
|
||||
if: steps.scope.outputs.mode == 'changed'
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Unit tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No src/ or tests/ changes in this PR — nothing to run."
|
||||
|
||||
int:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.test_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
# Matches production Neon major (17) and docker-compose.yml.
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
|
|
@ -153,39 +191,31 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Compute affected test scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/test-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Migrate test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm migrate
|
||||
|
||||
- name: Seed minimal test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm db:seed:minimal
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
|
||||
- name: Integration tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Integration tests (changed since PR base)
|
||||
if: steps.scope.outputs.mode == 'changed'
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Integration tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No src/ or tests/ changes in this PR — nothing to run."
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.build_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
|
|
@ -208,13 +238,23 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (against the local service database)
|
||||
run: pnpm build
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.e2e_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
|
|
@ -239,91 +279,31 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Compute affected e2e scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/e2e-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Migrate test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm migrate
|
||||
|
||||
- name: Seed minimal test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
# CI e2e runs against the production build (see playwright.config.ts
|
||||
# webServer.command); the dist dir must match the webServer env.
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next/e2e
|
||||
|
||||
- name: E2E tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.e2e_mode == 'full'
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: E2E tests (manifest-selected specs)
|
||||
if: steps.scope.outputs.mode == 'selected'
|
||||
run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }}
|
||||
|
||||
- name: E2E tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No e2e-relevant changes in this PR — nothing to run."
|
||||
|
||||
# Branch protection on `stage`/`main` still requires the legacy context name
|
||||
# `checks` from the pre-parallel monolithic job. This rollup satisfies that
|
||||
# contract without re-serializing the suite.
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint
|
||||
- format
|
||||
- typecheck
|
||||
- knip
|
||||
- cycles
|
||||
- unit
|
||||
- int
|
||||
- build
|
||||
- e2e
|
||||
- migration-lock
|
||||
if: always()
|
||||
steps:
|
||||
- name: All parallel checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
if: needs.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }}
|
||||
|
||||
migration-lock:
|
||||
# At most ONE open PR may touch schema (migrations or generated types).
|
||||
# Two in flight guarantees a broken rebase and a poisoned drizzle snapshot
|
||||
# chain; the second PR must wait or rebase.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
# gh pr list against the default GITHUB_TOKEN.
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
# gh expands --json files via git, so it needs the repo checked out.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Count open PRs touching schema files
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -337,3 +317,30 @@ jobs:
|
|||
echo "::error::More than one open PR touches src/migrations/ or payload-types.ts. Merge or close the other first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- scope
|
||||
- lint
|
||||
- format
|
||||
- typecheck
|
||||
- knip
|
||||
- cycles
|
||||
- unit
|
||||
- int
|
||||
- build
|
||||
- e2e
|
||||
- migration-lock
|
||||
if: always()
|
||||
steps:
|
||||
- name: All cascade checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
|
|
|
|||
77
.github/workflows/ci-stage.yml
vendored
77
.github/workflows/ci-stage.yml
vendored
|
|
@ -1,77 +0,0 @@
|
|||
# Stage gate: runs AFTER a merge lands on `stage`, against the stage database
|
||||
# (Neon branch cloned from prod, secret STAGE_DATABASE_URL in the `stage`
|
||||
# GitHub Environment).
|
||||
#
|
||||
# HARD RULE: never `pnpm build` here — the build step runs `payload migrate`
|
||||
# against whatever DATABASE_URL it sees, and this workflow holds a stage URL.
|
||||
# No db:seed:minimal either: stage data is the prod snapshot, not the synthetic
|
||||
# minimal set.
|
||||
name: CI (stage)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [stage]
|
||||
|
||||
concurrency:
|
||||
group: ci-stage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stage-checks:
|
||||
runs-on: ubuntu-latest
|
||||
# The int suite against a remote Neon snapshot is latency-bound (~120 ms
|
||||
# RTT per query): the FULL suite blew the 30-minute timeout twice on
|
||||
# 2026-07-30, so stage runs a curated smoke subset (access lockdown +
|
||||
# consent-gated flows — the things a prod-snapshot run is FOR). The full
|
||||
# int suite gates in ci-pr.yml against the local service DB. Subset rule:
|
||||
# only specs safe against REAL data — own-fixture creates are fine; specs
|
||||
# that delete/mutate seeded rows are not (submitWhatsapp deletes the keyed
|
||||
# Consent to test fail-closed, and campaignMigrationReconciliation creates
|
||||
# scratch databases; both stay out).
|
||||
timeout-minutes: 45
|
||||
environment: stage
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.STAGE_DATABASE_URL }}
|
||||
PAYLOAD_SECRET: ${{ secrets.STAGE_PAYLOAD_SECRET }}
|
||||
NEXT_PUBLIC_SITE_URL: https://teqo-stage.vercel.app
|
||||
# The seed/migrate guards refuse non-local hosts without this flag. Stage
|
||||
# is the ONE documented remote target, and only this workflow may use it.
|
||||
ALLOW_REMOTE_DB: 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Apply pending migrations to the stage database
|
||||
run: pnpm migrate
|
||||
|
||||
- name: Point the test env at the stage snapshot
|
||||
run: |
|
||||
{
|
||||
echo "DATABASE_URL=${DATABASE_URL}"
|
||||
echo "PAYLOAD_SECRET=${PAYLOAD_SECRET}"
|
||||
} > .env.test
|
||||
|
||||
- name: Integration smoke tests against the stage snapshot
|
||||
run: >
|
||||
pnpm test:int
|
||||
--testTimeout=120000 --hookTimeout=120000
|
||||
tests/int/collectionAccessLockdown.int.spec.ts
|
||||
tests/int/campaignMunicipalityAccess.int.spec.ts
|
||||
tests/int/campaignVotePledge.int.spec.ts
|
||||
tests/int/campaignSupporter.int.spec.ts
|
||||
tests/int/campaignDemandWorkflow.int.spec.ts
|
||||
tests/int/onda0Provision.int.spec.ts
|
||||
env:
|
||||
# Documented escape hatch in tests/helpers/assertTestDatabase.ts —
|
||||
# only this workflow may set it (vitest.setup.ts loads .env.test
|
||||
# with override: true, written by the step above).
|
||||
ALLOW_STAGE_TEST_DB: 'true'
|
||||
103
.github/workflows/ci.yml
vendored
103
.github/workflows/ci.yml
vendored
|
|
@ -1,23 +1,19 @@
|
|||
# Continuous integration on GitHub Actions (GitHub is the only home).
|
||||
# Post-merge gate on `main` — the FULL safety net, always: the promote PR
|
||||
# already passed ci-pr (possibly with affected-test selection), so this run
|
||||
# re-verifies everything against the minimal synthetic database, including
|
||||
# build and full e2e.
|
||||
# PRs run in ci-pr.yml; merges to `stage` run in ci-stage.yml (stage Neon
|
||||
# snapshot smoke — never a build there).
|
||||
# Post-merge verifier on `main`: always the FULL suite (no affected skips).
|
||||
# When green, deploys production via `vercel deploy --prod` (Vercel Git builds
|
||||
# are ignored — see scripts/vercel-ignore-build.sh). If HEAD moved while this
|
||||
# run was in flight, `requeue` dispatches another run for the new tip.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: main-verify
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Tests load .env.test themselves (database teqo_test on localhost:5432).
|
||||
# These are only for the migrate/seed/build steps, which read the process env.
|
||||
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
|
@ -34,6 +30,7 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: node scripts/check-test-locations.mjs
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm typecheck
|
||||
|
|
@ -46,7 +43,6 @@ jobs:
|
|||
timeout-minutes: 25
|
||||
services:
|
||||
postgres:
|
||||
# Matches production Neon major (17) and docker-compose.yml.
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
|
|
@ -96,7 +92,6 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (against the local service database)
|
||||
run: pnpm build
|
||||
|
||||
|
|
@ -125,29 +120,24 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build for e2e
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next/e2e
|
||||
|
||||
- name: E2E tests (full suite)
|
||||
run: pnpm test:e2e
|
||||
|
||||
# Same legacy branch-protection context as ci-pr.yml (see comment there).
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs: [static, int, build, e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: All parallel checks green
|
||||
- name: All main verifier checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
|
|
@ -156,3 +146,78 @@ jobs:
|
|||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checks]
|
||||
if: needs.checks.result == 'success'
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Guard stale HEAD
|
||||
id: tip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --quiet
|
||||
tip=$(git rev-parse origin/main)
|
||||
if [ "$tip" != "$GITHUB_SHA" ]; then
|
||||
echo "Skip deploy: origin/main is $tip, this run verified $GITHUB_SHA"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
- name: Require Vercel credentials
|
||||
if: steps.tip.outputs.stale != 'true'
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
[ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN)
|
||||
[ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID)
|
||||
[ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID)
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::Missing repo secrets for gated deploy: ${missing[*]}. Add them under Settings → Secrets, then re-run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
- name: Deploy production
|
||||
if: steps.tip.outputs.stale != 'true'
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npx --yes vercel@41 pull --yes --environment=production --token="$VERCEL_TOKEN"
|
||||
npx --yes vercel@41 build --prod --token="$VERCEL_TOKEN"
|
||||
npx --yes vercel@41 deploy --prebuilt --prod --token="$VERCEL_TOKEN"
|
||||
|
||||
requeue:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checks, deploy]
|
||||
if: always()
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Re-dispatch if main advanced
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --quiet
|
||||
tip=$(git rev-parse origin/main)
|
||||
if [ "$tip" = "$GITHUB_SHA" ]; then
|
||||
echo "HEAD still $GITHUB_SHA — idle"
|
||||
exit 0
|
||||
fi
|
||||
echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml"
|
||||
gh workflow run ci.yml --ref main
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
name: Stage merge flips claimed issues to done
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -11,18 +11,18 @@ permissions:
|
|||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: github.event.pull_request.base.ref == 'stage' && github.event.pull_request.merged == true
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- name: Flip Closes/Fixes #N issues from in-progress to done
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: |
|
||||
body="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq .body)"
|
||||
numbers="$(printf '%s' "$body" | grep -oiE '(closes|fixes) #[0-9]+' | grep -oE '[0-9]+' | sort -u)"
|
||||
for n in $numbers; do
|
||||
gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done || true
|
||||
gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done --add-label in-prod || true
|
||||
gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "Merged em stage via PR #${PR_NUMBER} — done; \`in-prod\` entra no auto-promote após CI stage green." || true
|
||||
--body "Merged em main via PR #${PR_NUMBER} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated)." || true
|
||||
done
|
||||
94
.github/workflows/promote-stage-to-main.yml
vendored
94
.github/workflows/promote-stage-to-main.yml
vendored
|
|
@ -1,94 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -4,6 +4,10 @@ out/
|
|||
build/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright-cli/
|
||||
.aider.chat.history.md
|
||||
.aider.input.history
|
||||
.aider.tags.cache.v4/
|
||||
data/
|
||||
pnpm-lock.yaml
|
||||
.impeccable/hook.cache.json
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
|
|||
|
||||
## Histórico de entregas (movido)
|
||||
|
||||
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: vivem em [`docs/CHANGELOG-AGENTS.md`](docs/CHANGELOG-AGENTS.md). Novas entregas registram UMA entrada curta lá, não aqui. A referência genérica de Payload (config/collections/fields/hooks/queries/components) foi para [`docs/PAYLOAD-REFERENCE.md`](docs/PAYLOAD-REFERENCE.md) — consulta sob demanda, não always-on. Operação do paradigma de agentes paralelos (claim→PR→stage→promote humano): [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md).
|
||||
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: vivem em [`docs/CHANGELOG-AGENTS.md`](docs/CHANGELOG-AGENTS.md). Novas entregas registram UMA entrada curta lá, não aqui. A referência genérica de Payload (config/collections/fields/hooks/queries/components) foi para [`docs/PAYLOAD-REFERENCE.md`](docs/PAYLOAD-REFERENCE.md) — consulta sob demanda, não always-on. Operação do paradigma de agentes paralelos (claim→PR→main→deploy gated): [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md).
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -7,15 +7,15 @@
|
|||
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas:
|
||||
>
|
||||
> 1. O agente roda `pnpm agent:claim` e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`).
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e abre **PR para `stage`** — sozinho, e para aí.
|
||||
> 3. CI verde → o PR mergeia em `stage` automaticamente; o merge roda o CI de stage contra o clone Neon de prod.
|
||||
> 4. **Você** promove quando quiser: `pnpm agent:promote -- --i-am-human` (PR `stage→main`, merge só com CI stage + PR green).
|
||||
> 5. Semanalmente: `pnpm db:refresh:stage` renova o snapshot do stage a partir de prod (humano, requer `NEON_API_KEY`).
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e abre **PR para `main`** — sozinho.
|
||||
> 3. CI verde → o PR mergeia em `main` automaticamente; o verificador `ci.yml` roda a suíte completa.
|
||||
> 4. Suíte verde → Action dispara `vercel deploy --prod` (Vercel Git builds estão desligados).
|
||||
> 5. Secrets humanos (uma vez): `VERCEL_*`, `POOL_GITHUB_TOKEN`; `pnpm configure:branch-protection`.
|
||||
>
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | agent:promote` e `pnpm db:seed:minimal | db:refresh:stage`.
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | agent:pool` e `pnpm db:seed:minimal`.
|
||||
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → stage. **Só humano:** promote `stage→main`, refresh do stage, envs Vercel/Neon, `pnpm build` contra banco remoto.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` + `ci-stage.yml` + `ci.yml`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** secrets Vercel/pool, branch protection, envs Neon.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` + `ci.yml`.
|
||||
|
||||
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,105 +1,89 @@
|
|||
# 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 via auto-promote quando o CI stage fica verde.
|
||||
Uma página. Norte: simplicidade (só GitHub + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
|
||||
|
||||
## Ambientes de dados (test ladder — nunca misturar)
|
||||
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI de PR |
|
||||
| **Stage** (Neon branch `stage`, clone de prod) | snapshot 100% de prod (PII!) | só CI `ci-stage.yml` + humanos em smoke controlado |
|
||||
| **Prod** (Neon prod) | real | deploy Vercel a partir de `main` apenas |
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (Neon prod) | real | deploy Vercel gated por `ci.yml` |
|
||||
|
||||
Agentes **nunca** recebem `DATABASE_URL` de stage/prod e nunca setam `ALLOW_REMOTE_DB`. O escape `ALLOW_STAGE_TEST_DB` (`tests/helpers/assertTestDatabase.ts`) só existe para o workflow `ci-stage.yml`.
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke Neon `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```text
|
||||
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
|
||||
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
|
||||
main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HEAD andou
|
||||
```
|
||||
|
||||
**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.
|
||||
**Skills:** `plan-issue` → `work-issue` (claim → modelo → freshness → execução → /simplify → PR `--base main` → CI até o merge) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues.
|
||||
|
||||
- **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`.
|
||||
- **Agente faz sozinho:** `pnpm agent:claim` → implementa → **`pnpm push`** → `gh pr create --base main` com `Closes #N` (**Ready**) → `gh pr merge --auto --merge` → `gh pr checks --watch --required`. Regra: `.cursor/rules/agent-pr-workflow.mdc`.
|
||||
- **Só humano:** secrets Vercel (`VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`), `POOL_GITHUB_TOKEN`, `pnpm configure:branch-protection`, editar envs Neon/Vercel.
|
||||
|
||||
### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50)
|
||||
### Dono do PR, dono do CI
|
||||
|
||||
**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`).
|
||||
**Done = PR mergeado em `main` com CI verde.** Falha de CI no teu PR — infra, teste pré-existente ou regressão — é tua até o merge. Exceção de blast radius: migration / access / Consent → pare e escale.
|
||||
|
||||
- **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 (promove o último commit ci-stage green em `stage`, mesmo com head vermelho).
|
||||
- **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.
|
||||
### Bypass de CI (cutover / transição)
|
||||
|
||||
- **Local:** se `gate:push` ainda espelha pipeline antigo e bloqueia push legítimo da PR que o muda → `git push --no-verify` permitido como escape de transição (não WIP permanente). Documentado aqui; após o cutover, entregas passam no CI novo sem bypass.
|
||||
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `checks` + `migration-lock` em `main` assim que o CI novo estiver verde.
|
||||
|
||||
## Comandos
|
||||
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief no stdout. Deps sem Issue = itens entregues do roadmap (satisfeitas, avisadas no brief) |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (spec + labels + frontmatter com `model:` quando informado) |
|
||||
| `pnpm agent:status` | Read-only: overview, fila na ordem do claim (com `model:`), mermaid, bloqueios, sugestões de consolidação |
|
||||
| `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` | **Override manual:** PR `main` ← último commit em `stage` com ci-stage green (não exige head verde; auto-promote só no head) |
|
||||
| `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`) |
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks` + `migration-lock`, `strict=false`, 0 reviews |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
|
||||
Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:feature|defect|chore|agent-miss`, `needs:migration|consent`, `requirements-changed`. Issues carregam frontmatter `id/depends/serializes/priority/model` no body.
|
||||
`pnpm agent:promote` e `pnpm db:refresh:stage` são **legado** (recusam / não fazem parte do fluxo). Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
|
||||
## Contrato de PR (toda entrega)
|
||||
## Contrato de PR
|
||||
|
||||
1. Se toca schema (migration, collection, Consent key, dado de boot) → **atualizar `db:seed:minimal` no mesmo PR** (label `needs:migration`). CI PR falha se `migrate + seed:minimal + test:int` não passam.
|
||||
2. ≤1 PR aberto tocando `src/migrations/` | `payload-types.ts` (job `migration-lock`).
|
||||
3. `docs/plans/<slug>.md` novo só para Issue nova — nunca recriar plans históricos no merge.
|
||||
4. **Ready + auto-merge:** ver `.cursor/rules/agent-pr-workflow.mdc` — `gh pr create --base stage` (sem `--draft`) → `gh pr merge --auto --merge`; acompanhar com `gh pr checks --watch --required` (Vercel não é gate).
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
2. ≤1 PR aberto tocando migrations / `payload-types.ts` (`migration-lock`).
|
||||
3. Ready + auto-merge: `gh pr create --base main` → `gh pr merge --auto --merge`; `gh pr checks --watch --required` (Vercel Git não é gate).
|
||||
|
||||
## 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`) | **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 |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; rollup `checks` |
|
||||
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `in-progress` → `done` + `in-prod` |
|
||||
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
|
||||
|
||||
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`.
|
||||
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
||||
|
||||
## Stage DB — runbook de refresh (semanal, humano)
|
||||
### Secrets (humano, uma vez)
|
||||
|
||||
1. `export NEON_API_KEY=…` (console.neon.tech → API keys; projeto Neon da campanha (id via `NEON_PROJECT_ID`; não hardcodar no repo público)).
|
||||
2. `pnpm db:refresh:stage` — deleta a branch `stage` velha, cria nova do snapshot de prod, faz swap do secret `STAGE_DATABASE_URL` no Environment `stage`. `--dry-run` inspeciona.
|
||||
3. Próximo `ci-stage` já usa o snapshot novo. Preview Vercel da branch `stage` (`jorgesolla-git-stage-solla.vercel.app`) aponta para o mesmo banco via envs `Preview (stage)` — **atenção**: o endpoint muda a cada refresh; rode também `vercel env add DATABASE_URL preview stage` com a URL nova (o script imprime a URL mascarada; pegue a completa no console Neon ou rode `vercel env pull`).
|
||||
| Secret | Uso |
|
||||
| ------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `VERCEL_TOKEN` | Deploy gated em `ci.yml` |
|
||||
| `VERCEL_ORG_ID` | idem |
|
||||
| `VERCEL_PROJECT_ID` | idem |
|
||||
| `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) |
|
||||
|
||||
Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branches (incl. `main`) — prod só via Action.
|
||||
|
||||
## Cursor Cloud
|
||||
|
||||
`.cursor/environment.json` instala deps, sobe Postgres nativo (`ensure-postgres.sh` — VMs Cloud não rodam Docker/compose), migra e roda `db:seed:minimal` — clone + `gh` + DB mínimo bastam. Sem secrets de stage/prod. Hooks do repo são portáteis (paths relativos em `.cursor/hooks.json`); hooks pessoais (ex.: impeccable) ficam no `~/.cursor/hooks.json` do usuário.
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
|
||||
|
||||
**Agentes Cloud (skill `work-issue`):** renomear sessão após claim (`rename_chat:` no brief); **não** usar `pnpm db:start` (Docker); **não** `git push --no-verify` cru — usar `pnpm push` (roda `gate:push` antes). `gate:fast`/`gate:push` não precisam de Postgres (`test:unit` não conecta); int/migrate usam o Postgres nativo do install.
|
||||
## Agent pool
|
||||
|
||||
## Agent pool (orquestrador de workers Cloud)
|
||||
Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` + dispatch. Workers PR `--base main`. Default model `composer-2.5`. **Secret `POOL_GITHUB_TOKEN`** obrigatório se `GITHUB_TOKEN` 403 em `/actions/variables`. Doctor: `pnpm agent:pool -- doctor`. Skill: `agent-pool`.
|
||||
|
||||
Supervisor **remoto** que mantém até 5 Cursor Cloud Agents rodando `work-issue` sobre Issues `ready` elegíveis para autonomia (label `ready`, sem `blocked`/`requirements-changed`/`needs:consent`, deps satisfeitas; `needs:migration` cede ao `migration-lock`). Implementação: `.github/workflows/agent-pool.yml` (tick stateless: `schedule */10min` + `pull_request` closed em `stage` + `workflow_dispatch`) rodando `scripts/agent-pool.mjs`; workers spawnados via Cloud Agents API v1 com o `model:` da Issue (fallback `composer-2.5`). Canal canônico humano: `gh workflow run agent-pool.yml -f action=start|stop|pause|resume` (wrappers: `pnpm agent:pool -- start|stop|pause|resume|status|doctor`). Estado escalar em repo variables `POOL_*`; ativos derivados (marcadores `pool-worker` nos comentários + status dos runs). Claim coordenado pelo supervisor (alocador único, sem race); falha terminal → Issue a `blocked` para triagem; fila drenada → pool se desliga. **Nunca** chama `agent:promote`. Requer repo secret `CURSOR_API_KEY`; `schedule`/trigger de PR só ativam após merge em `main`. Arquitetura e runbook: [`docs/plans/agent-pool-orchestrator.md`](plans/agent-pool-orchestrator.md) + skill `agent-pool`.
|
||||
## Guardrails / leitura
|
||||
|
||||
## Guardrails progressivos
|
||||
|
||||
`pnpm agent:file-miss` → Issue `kind:agent-miss` → harvest periódico → guardrail programático (spec `codebaseConventions`, ESLint, check CI) → ledger `docs/GUARDRAILS.md`. Padrão já fichado: "migration sem atualizar seed:minimal".
|
||||
|
||||
## Leitura always-on (ordem)
|
||||
|
||||
`AGENTS.md` (fatiado 2026-07-30) → `.cursor/rules/codebase-map.mdc` → `.cursor/rules/engineering-standards.mdc` → esta página → kernels [`PRODUCT.md`](../PRODUCT.md) / [`DESIGN.md`](../DESIGN.md) / [`CUSTOMER.md`](CUSTOMER.md) (kernel no topo). Histórico: [`CHANGELOG-AGENTS.md`](CHANGELOG-AGENTS.md). Referência Payload sob demanda: [`PAYLOAD-REFERENCE.md`](PAYLOAD-REFERENCE.md).
|
||||
|
||||
## Material sensível: `/private/` (local) vs cloud
|
||||
|
||||
O repo GitHub é **público** e proprietário (All Rights Reserved). Discovery de campo com áudio, transcrição bruta, nomes reais, deltas operacionais de voto e planilhas de projeção **não** sobem no tip público.
|
||||
|
||||
| Quem | Onde grava | O que faz |
|
||||
| -------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Agente local** | `/private/` (gitignored; só `private/README.md` versionado) | Lê/escreve notas sensíveis aí. Publica em `docs/` só uma **versão limpa** (JTBD/insights sem PII operacional). Nunca `git add` de áudio/transcribe/sheets/notas verbatim. |
|
||||
| **Agente cloud** (Cursor Cloud, Devin, etc.) | Clone **sem** `/private/` | Baseia-se em `docs/CUSTOMER.md` e planos públicos limpos. Se o entregável for sensível, **informa no chat** e **não commitá** — o humano salva em `/private/` no checkout local. |
|
||||
|
||||
Regra de ouro: o que um cloud agent não deveria ver no GitHub público também não deve ser commitado por um agente local.
|
||||
`pnpm agent:file-miss` → harvest → `docs/GUARDRAILS.md`. Always-on: `AGENTS.md` → codebase-map → engineering-standards → esta página → PRODUCT/DESIGN/CUSTOMER.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-07-31):** **Cloud `ensure-postgres` falso negativo no start** — o script tratava “role/DBs `teqo` prontos” como “Postgres aceitando conexões”. Em snapshot warm o cluster já está `online` sem role/DBs; `start_postgres` falhava com `postgres is installed but not accepting connections` **antes** de `bootstrap_databases`, o `start` do `environment.json` saía ≠0 e a UI marcava `INSTALL_FAILED` mesmo com o agente utilizável. Fix: separar `server_ready` (`pg_isready` / peer) de `teqo_ready`, bootstrap após o server up, retry no wait; `cloud-setup.sh` pina `DATABASE_URL` e asserta `payload_migrations` pós-migrate (no-op silencioso visto no JIT). Ruído cosmético no mesmo log (dirs de recording, `.installed` de plugins ENOENT) é race do exec-daemon — não é do Teqo.
|
||||
|
||||
**Recently resolved (2026-07-31):** **Pass 4 — auditoria de engenharia autônoma + guardrails das misses** — primeira execução em modo autônomo (Cursor Cloud) da skill engineering-audit, com precheck solitário (pool desligado) e primeiro harvest de `kind:agent-miss` (escopo OPS2: nasce `docs/GUARDRAILS.md`). **P0:** os 4 globals públicos (`site-settings`/`home`/`metadata`/`privacy-policy`) aceitavam qualquer autenticado no `update` — JWT de campanha (liderança inclusa) editava o site público via `/api/globals/*`; lockdown `payloadAdminOnly` + pins + guarda ([PR #91](https://github.com/fsolla/teqo/pull/91); editores perdem escrita nos globals de config deliberadamente). **P1:** `seed-posts.mjs` chamava `dieWithLabel` sem importar ([PR #92](https://github.com/fsolla/teqo/pull/92), com guarda de presença de import + `DAY_MS` single-source + ESLint lib→utilities sem type-import + guarda de `overrideAccess` omitido). **Misses:** #73 (deadlock `municipality_rels` — classe reproduzida: 7 violações int + 11 deep links e2e; migração para o alocador com teste de classe reescrito classe-agnóstico + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (Drawer por célula em 3 listas de chips → `CampaignListSheetProvider` + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (portão de readiness do autenticador virtual) + #54 (guarda de goto não assentado + convenção TESTING.md) em [PR #95](https://github.com/fsolla/teqo/pull/95); #83 fechada (guarda `agent:ship` já viva). **Registrado, não retry-fixed:** spec de chips de `campaignLeaderships` falha deterministicamente em dev na árvore limpa (nenhum POST dispara; prod 48/48 verde) — causa raiz via debug workflow (P4-L). Artefatos: seção Pass 4 no plano de qualidade, 15 linhas novas no ledger (2 stale fechadas: P3-A/P3-B), plano [`entrega-engenharia-p4.md`](docs/plans/entrega-engenharia-p4.md) com ondas P4-A→P4-L + mapa de guardas + defer+gatilho.
|
||||
|
||||
**Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full.
|
||||
|
|
|
|||
19
docs/CUTOVER-MAIN-ONLY.md
Normal file
19
docs/CUTOVER-MAIN-ONLY.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Cutover checklist (main-only + gated deploy) — human, once
|
||||
|
||||
Status after agent delivery (2026-07-31):
|
||||
|
||||
- [x] Code PR opened (main-only cutover) — see PR targeting `main`
|
||||
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` + `migration-lock`, `strict=false`, 0 reviews)
|
||||
- [x] No open PRs targeting `stage` at cutover time
|
||||
- [ ] Repo secrets still needed:
|
||||
- `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` (deploy job fail-closed until set)
|
||||
- `POOL_GITHUB_TOKEN` (PAT: `actions:write` + `issues:write` + `contents:read`) — `PROMOTE_GITHUB_TOKEN` already exists; reuse/rename if scopes cover Actions variables, else mint a new PAT
|
||||
|
||||
After the cutover PR merges to `main`:
|
||||
|
||||
1. Validate Vercel Git skip: push to `main` must **not** start a Vercel Git build (`ignoreCommand` exit 0). Production comes only from `ci.yml` → deploy.
|
||||
2. Confirm `ci.yml` runs full suite; deploy job runs or fails closed with a clear missing-secrets message until `VERCEL_*` are set.
|
||||
3. `POOL_GITHUB_TOKEN=… pnpm agent:pool -- doctor` must read repo variables.
|
||||
4. Optionally delete branch `stage` and its Environment/secrets when idle.
|
||||
|
||||
See [AGENT-OPS.md](AGENT-OPS.md).
|
||||
|
|
@ -12,6 +12,8 @@ CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings),
|
|||
|
||||
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
|
||||
|
||||
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat:** `campaignLeaderships`' chip-cell spec currently fails deterministically under `pnpm dev` (suggestion/commit step; prod build green — run it with `E2E_PROD=1` locally; ledgered in TECH-DEBT).
|
||||
|
||||
## Minimal test database (`pnpm db:seed:minimal`)
|
||||
|
||||
The integration suite and any agent/Cloud environment boot from a **minimal synthetic database**: schema from `pnpm migrate` (which seeds the 435-município catalog) plus `pnpm db:seed:minimal` ([`scripts/seed-minimal.mjs`](../scripts/seed-minimal.mjs)), an idempotent upsert of the rows the app refuses to run without:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Responsável: —
|
|||
|
||||
## Contexto
|
||||
|
||||
O PR #82 (A6) entrega o card **"Dobradinhas potenciais para 2026"** na aba Eleições do detalhe do município: candidatos proporcionais com `runningAgain2026 = sim`, ranqueados por score `0,6 alinhamento + 0,4 força local 2022`. Enquanto o TSE não publica as candidaturas de 2026 e a Fase 5 não reconcilia `runningAgain2026`, o loader retorna `{ status: 'pending2026' }` e o card mostra *"Indisponível até as candidaturas de 2026"*.
|
||||
O PR #82 (A6) entrega o card **"Dobradinhas potenciais para 2026"** na aba Eleições do detalhe do município: candidatos proporcionais com `runningAgain2026 = sim`, ranqueados por score `0,6 alinhamento + 0,4 força local 2022`. Enquanto o TSE não publica as candidaturas de 2026 e a Fase 5 não reconcilia `runningAgain2026`, o loader retorna `{ status: 'pending2026' }` e o card mostra _"Indisponível até as candidaturas de 2026"_.
|
||||
|
||||
Os dados de **2022 já estão no banco** (`electionCandidate` + `electionCandidateVote` via `pnpm db:seed:tse`). O staff precisa da inteligência **agora** — quem teve força local em 2022 e qual o alinhamento partidário — para priorizar dobradinhas antes de agosto.
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ Os dados de **2022 já estão no banco** (`electionCandidate` + `electionCandida
|
|||
|
||||
## Questões em aberto — RESOLVIDAS (proposta)
|
||||
|
||||
- **Copy do estado provisório** — RESOLVIDA. Título mantém "Dobradinhas potenciais para 2026"; subtítulo/aviso: *"Lista provisória com base em quem concorreu aqui em 2022. Atualiza automaticamente quando o TSE publicar as candidaturas de 2026."*
|
||||
- **Copy do estado provisório** — RESOLVIDA. Título mantém "Dobradinhas potenciais para 2026"; subtítulo/aviso: _"Lista provisória com base em quem concorreu aqui em 2022. Atualiza automaticamente quando o TSE publicar as candidaturas de 2026."_
|
||||
- **Candidatos `runningAgain2026 = desconhecido` após reconcile** — RESOLVIDA. Fora da lista confirmada (mesmo critério conservador de A6: só `sim` entra). Não inventar parceiro sem confirmação.
|
||||
- **Badge "eleito em 2022"** — RESOLVIDA. Mantém no modo provisório e confirmado (campo `elected` já no registry).
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*
|
||||
* Output: the brief on stdout (issue, id, priority, plan/spec body) — the
|
||||
* agent reads it and starts; it does NOT create the branch (Cursor worktrees
|
||||
* own that) and it never merges anywhere. PRs go `--base stage`.
|
||||
* own that) and it never merges anywhere. PRs go `--base main`.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -119,6 +119,6 @@ console.log(` url: https://github.com/fsolla/teqo/issues/${pick.issue.number}`)
|
|||
console.log('\n--- spec ---\n')
|
||||
console.log(rest.trim() || '(empty body — see linked plan)')
|
||||
console.log(
|
||||
'\n[agent:claim] Fluxo: rename_chat (cursor-app-control) → implementar → gate:fast → pnpm push -u origin HEAD → gh pr create --base stage (Closes #' +
|
||||
'\n[agent:claim] Fluxo: rename_chat (cursor-app-control) → implementar → gate:fast → pnpm push -u origin HEAD → gh pr create --base main (Closes #' +
|
||||
`${pick.issue.number}) → PARAR. Promote stage→main é automático após CI stage green.`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
* pnpm agent:pool -- doctor # pré-requisitos (gh, variables, CURSOR_API_KEY, modelos)
|
||||
* pnpm agent:pool -- start|stop|pause|resume [-- --ref <branch>] [-- --max-slots N]
|
||||
*
|
||||
* Promote stage→main is never part of the pool — it stays human
|
||||
* (`pnpm agent:promote -- --i-am-human`).
|
||||
* Promote is not part of the pool — workers PR directly to main.
|
||||
* (legacy; removed from the happy path).
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
|
@ -68,7 +68,7 @@ import {
|
|||
} from './lib/agent-pool-state.mjs'
|
||||
|
||||
const REPO_URL = 'https://github.com/fsolla/teqo'
|
||||
const POOL_BASE_REF = 'stage'
|
||||
const POOL_BASE_REF = 'main'
|
||||
|
||||
const die = dieAgent('pool')
|
||||
const { flags, positional } = parseArgs(
|
||||
|
|
@ -348,7 +348,7 @@ const runTick = async ({ dryRun }) => {
|
|||
await archiveCursorAgent(spawn.agentId).catch(() => {})
|
||||
}
|
||||
log(` merge concluído #${claim.issue.number} — slot livre, agente arquivado.`)
|
||||
summary(`- #${claim.issue.number} mergeada em stage — slot livre`)
|
||||
summary(`- #${claim.issue.number} mergeada em main — slot livre`)
|
||||
}
|
||||
|
||||
const active = reconciliation.active
|
||||
|
|
@ -478,12 +478,22 @@ const runDoctor = async () => {
|
|||
} catch {
|
||||
log(' gh auth: FALHOU (rode `gh auth login`)')
|
||||
}
|
||||
if (process.env.POOL_GITHUB_TOKEN) {
|
||||
log(' POOL_GITHUB_TOKEN: presente no env (usará este token para variables)')
|
||||
} else if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) {
|
||||
log(
|
||||
' POOL_GITHUB_TOKEN: ausente — usando GH_TOKEN/GITHUB_TOKEN (GHA: configure o secret POOL_GITHUB_TOKEN se variables der 403)',
|
||||
)
|
||||
} else {
|
||||
log(' POOL_GITHUB_TOKEN / GH_TOKEN: ausente')
|
||||
}
|
||||
try {
|
||||
const vars = readPoolVariables()
|
||||
log(` repo variables: OK (${Object.keys(vars).length} lidas)`)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log(
|
||||
' repo variables: SEM PERMISSÃO (precisa de actions:read; GHA usa GITHUB_TOKEN com actions:write)',
|
||||
` repo variables: FALHOU (${message}). No Actions, use secrets.POOL_GITHUB_TOKEN (PAT com actions:write + issues:write) — GITHUB_TOKEN costuma 403 em /actions/variables.`,
|
||||
)
|
||||
}
|
||||
if (!process.env.CURSOR_API_KEY) {
|
||||
|
|
|
|||
|
|
@ -1,175 +1,20 @@
|
|||
/**
|
||||
* `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`.
|
||||
* `pnpm agent:promote` — REMOVED (main-only cutover).
|
||||
*
|
||||
* Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green on
|
||||
* the current stage head. Use this when the workflow failed or you need to promote
|
||||
* out of band — including when stage head is red but an older commit on stage
|
||||
* already passed ci-stage.
|
||||
* Feature PRs merge directly to `main`. Production deploy is gated by `ci.yml`
|
||||
* (`vercel deploy --prod` after the full suite). There is no stage→main promote.
|
||||
*
|
||||
* Checks (in order):
|
||||
* 1. `main` has not diverged from `stage` (hotfix direto em main exige merge
|
||||
* de main em stage antes).
|
||||
* 2. Newest commit on `origin/main..origin/stage` with a successful ci-stage run.
|
||||
* 3. PR into `main` from `stage` (head green) or `promote/last-green` (older green).
|
||||
* 4. Merge (ci-stage on the promoted SHA is the pre-promote gate; ci.yml on main
|
||||
* is the post-merge safety net).
|
||||
* See docs/AGENT-OPS.md.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
|
||||
import {
|
||||
findLastGreenPromoteSha,
|
||||
greenCiStageHeadShas,
|
||||
PROMOTE_GREEN_BRANCH,
|
||||
} from './lib/agent-promote-target.mjs'
|
||||
import { dieAgent, parseArgs } from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('promote')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set())
|
||||
parseArgs(process.argv.slice(2), new Set())
|
||||
|
||||
if (flags['i-am-human'] !== true) {
|
||||
die(
|
||||
'Refused: promote override is human-only. Re-run as a human with:\n' +
|
||||
' pnpm agent:promote -- --i-am-human\n' +
|
||||
'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green on stage head.',
|
||||
)
|
||||
}
|
||||
|
||||
const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
|
||||
git(['fetch', 'origin', 'main', 'stage'])
|
||||
|
||||
// 1. Divergence: main must be an ancestor of stage.
|
||||
try {
|
||||
git(['merge-base', '--is-ancestor', 'origin/main', 'origin/stage'])
|
||||
} catch {
|
||||
die(
|
||||
'`main` divergiu de `stage` (hotfix direto em main?). Antes do promote:\n' +
|
||||
' git checkout stage && git merge origin/main && git push origin stage\n' +
|
||||
'…e espere o CI stage ficar verde de novo.',
|
||||
)
|
||||
}
|
||||
|
||||
const stageSha = git(['rev-parse', 'origin/stage'])
|
||||
const mainSha = git(['rev-parse', 'origin/main'])
|
||||
|
||||
if (stageSha === mainSha) {
|
||||
die(`main já está no head de stage (${stageSha.slice(0, 8)}) — nada a promover.`)
|
||||
}
|
||||
|
||||
const commitsAhead = git(['log', `origin/main..origin/stage`, '--format=%H'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
const stageRuns = ghJson([
|
||||
'run',
|
||||
'list',
|
||||
'--branch',
|
||||
'stage',
|
||||
'--workflow',
|
||||
'ci-stage.yml',
|
||||
'--limit',
|
||||
'100',
|
||||
'--json',
|
||||
'headSha,status,conclusion,url',
|
||||
])
|
||||
|
||||
const promoteSha = findLastGreenPromoteSha(commitsAhead, greenCiStageHeadShas(stageRuns))
|
||||
|
||||
if (!promoteSha) {
|
||||
die(
|
||||
`Nenhum commit em stage à frente de main tem ci-stage verde (stage head ${stageSha.slice(0, 8)}). ` +
|
||||
'Corrija stage ou espere um run verde antes do promote.',
|
||||
)
|
||||
}
|
||||
|
||||
const promoteRun = stageRuns.find(
|
||||
(run) => run.headSha === promoteSha && run.status === 'completed' && run.conclusion === 'success',
|
||||
)
|
||||
|
||||
if (promoteSha === stageSha) {
|
||||
console.log(`[agent:promote] CI stage green no head de stage (${stageSha.slice(0, 8)}) ✓`)
|
||||
} else {
|
||||
console.log(
|
||||
`[agent:promote] stage head ${stageSha.slice(0, 8)} não está verde; ` +
|
||||
`promovendo último commit ci-stage green ${promoteSha.slice(0, 8)} ✓`,
|
||||
)
|
||||
if (promoteRun?.url) {
|
||||
console.log(`[agent:promote] run: ${promoteRun.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
const prHead = promoteSha === stageSha ? 'stage' : PROMOTE_GREEN_BRANCH
|
||||
|
||||
if (prHead !== 'stage') {
|
||||
execFileSync(
|
||||
'git',
|
||||
['push', 'origin', `${promoteSha}:refs/heads/${PROMOTE_GREEN_BRANCH}`, '--force'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 3. PR → main (create if needed).
|
||||
let pr = ghJson([
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
prHead,
|
||||
'--json',
|
||||
'number,url',
|
||||
])[0]
|
||||
|
||||
if (!pr && prHead !== 'stage') {
|
||||
const staleStagePr = ghJson([
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
'--json',
|
||||
'number,url',
|
||||
])[0]
|
||||
if (staleStagePr) {
|
||||
console.log(
|
||||
`[agent:promote] fechando PR #${staleStagePr.number} stage→main (head vermelho; promote em ${promoteSha.slice(0, 8)})`,
|
||||
)
|
||||
gh(['pr', 'close', String(staleStagePr.number)])
|
||||
}
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
const body =
|
||||
promoteSha === stageSha
|
||||
? 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green no head de stage.'
|
||||
: `Override manual via \`pnpm agent:promote --i-am-human\`. Promove ci-stage green \`${promoteSha.slice(0, 8)}\` (stage head \`${stageSha.slice(0, 8)}\` não está verde).`
|
||||
const url = gh([
|
||||
'pr',
|
||||
'create',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
prHead,
|
||||
'--title',
|
||||
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
|
||||
'--body',
|
||||
body,
|
||||
])
|
||||
pr = { number: url.split('/').pop(), url }
|
||||
console.log(`[agent:promote] created PR ${pr.url}`)
|
||||
} else {
|
||||
console.log(`[agent:promote] reusing open PR ${pr.url}`)
|
||||
}
|
||||
|
||||
// 4. Merge (ci-stage on promoteSha was the pre-promote gate; ci.yml on main is the safety net).
|
||||
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
|
||||
console.log(
|
||||
`[agent:promote] merged ${prHead} (${promoteSha.slice(0, 8)}) → main via PR #${pr.number}. Deploy Vercel a caminho.`,
|
||||
die(
|
||||
'Removed: stage→main promote no longer exists.\n' +
|
||||
' Open PRs with `gh pr create --base main`.\n' +
|
||||
' Production deploy runs from `.github/workflows/ci.yml` after the full verifier is green.\n' +
|
||||
' Details: docs/AGENT-OPS.md',
|
||||
)
|
||||
|
|
|
|||
79
scripts/check-test-locations.mjs
Normal file
79
scripts/check-test-locations.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-test-locations — always-on guard against misplaced *.spec|*.test files
|
||||
* that the affected classifier would miss (false-green skip). Walks the repo
|
||||
* (ignoring node_modules, .git, build artifacts) and fails if any spec/test
|
||||
* file lives outside the three canonical trees.
|
||||
*
|
||||
* Pure predicates live in scripts/lib/test-affected-core.mjs; this CLI is the
|
||||
* fs walk used by the lint job and by ciSkipInvariants.
|
||||
*/
|
||||
import { readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { findMisplacedSpecPaths } from './lib/test-affected-core.mjs'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const IGNORE_DIRS = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'dist',
|
||||
'build',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
'.playwright-cli',
|
||||
'.aider.tags.cache.v4',
|
||||
'data',
|
||||
])
|
||||
|
||||
function walk(dir, out = []) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env.test') {
|
||||
if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue
|
||||
if (entry.isDirectory() && entry.name !== '.github' && entry.name !== '.cursor') continue
|
||||
}
|
||||
if (IGNORE_DIRS.has(entry.name)) continue
|
||||
const absolute = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolute, out)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
out.push(path.relative(repoRoot, absolute).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function collectRepoRelativeFiles(root = repoRoot) {
|
||||
return walk(root)
|
||||
}
|
||||
|
||||
export function checkTestLocations(root = repoRoot) {
|
||||
const files = collectRepoRelativeFiles(root)
|
||||
return findMisplacedSpecPaths(files)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
|
||||
if (isMain) {
|
||||
const misplaced = checkTestLocations()
|
||||
if (misplaced.length > 0) {
|
||||
console.error(
|
||||
`[check-test-locations] misplaced spec/test files (must live under tests/{unit,int,e2e} with the canonical suffix):\n ${misplaced.join('\n ')}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[check-test-locations] ok — all specs in canonical trees')
|
||||
}
|
||||
67
scripts/ci-scope.mjs
Normal file
67
scripts/ci-scope.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ci-scope — single classifier for PR job-level skips. Prints JSON:
|
||||
* { base, code, build, test, e2e } where each of code/build/test/e2e is the
|
||||
* classify* / selectE2eSpecs result. Used by the `scope` job in ci-pr.yml.
|
||||
*
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
classifyTestScope,
|
||||
selectE2eSpecs,
|
||||
} from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
const full = {
|
||||
mode: 'full',
|
||||
reason: `no merge-base with ${base} (shallow clone?)`,
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
base,
|
||||
code: { mode: 'code', reason: full.reason },
|
||||
build: { mode: 'build', reason: full.reason },
|
||||
test: { ...full },
|
||||
e2e: { mode: 'full', specs: [], reason: full.reason, unmapped: [] },
|
||||
}),
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const files = raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, path] = line.split('\t')
|
||||
return { status, path }
|
||||
})
|
||||
|
||||
const e2e = selectE2eSpecs(files, E2E_AFFECTED_MANIFEST)
|
||||
if (e2e.unmapped.length > 0) {
|
||||
console.error(
|
||||
`[ci-scope] src/ paths with no e2e manifest mapping:\n ${e2e.unmapped.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
base,
|
||||
code: classifyStaticScope(files),
|
||||
build: classifyBuildScope(files),
|
||||
test: classifyTestScope(files),
|
||||
e2e,
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
/**
|
||||
* One-time / drift repair for branch protection aligned with auto-promote + parallel agents.
|
||||
* One-time / drift repair for branch protection (main-only).
|
||||
*
|
||||
* 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.
|
||||
* main: required checks `checks` + `migration-lock`, strict=false, 0 reviews.
|
||||
* Feature PRs auto-merge when those contexts are green.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
|
@ -41,13 +37,20 @@ const ghApiJson = (method, path, body) => {
|
|||
|
||||
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('PUT', `repos/${repo}/branches/main/protection`, {
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
contexts: ['checks', 'migration-lock'],
|
||||
},
|
||||
enforce_admins: false,
|
||||
required_pull_request_reviews: {
|
||||
required_approving_review_count: 0,
|
||||
},
|
||||
restrictions: null,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: false,
|
||||
})
|
||||
|
||||
ghApiJson('PATCH', `repos/${repo}/branches/main/protection/required_pull_request_reviews`, {
|
||||
required_approving_review_count: 0,
|
||||
})
|
||||
|
||||
console.log('[configure:branch-protection] done.')
|
||||
console.log(
|
||||
'[configure:branch-protection] done (main: checks + migration-lock, strict=false, 0 reviews).',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { execFileSync } from 'node:child_process'
|
|||
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
|
||||
import { selectE2eSpecs } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): static checks always;
|
||||
* unit/int/e2e follow the same blast-radius selection as CI (`test-affected.mjs`,
|
||||
* `e2e-affected.mjs` vs `origin/stage`). Build always runs. `migration-lock` is
|
||||
* GitHub-only.
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
|
||||
* then phase-2 expensive (int/build/e2e). Skips align with `scripts/ci-scope.mjs`
|
||||
* (`origin/main` by default). `migration-lock` is GitHub-only.
|
||||
*
|
||||
* Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape:
|
||||
* `git push --no-verify`.
|
||||
* Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape
|
||||
* during pipeline cutover: `git push --no-verify` (documented in AGENT-OPS).
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
|
@ -43,7 +42,7 @@ const readJsonScript = (script) => {
|
|||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
})
|
||||
return JSON.parse(raw.trim())
|
||||
return JSON.parse(raw.trim().split('\n').filter(Boolean).at(-1))
|
||||
}
|
||||
|
||||
const run = (label, command, args, env = process.env) => {
|
||||
|
|
@ -60,56 +59,68 @@ const run = (label, command, args, env = process.env) => {
|
|||
}
|
||||
|
||||
const main = async () => {
|
||||
const testScope = readJsonScript('scripts/test-affected.mjs')
|
||||
const e2eScope = readJsonScript('scripts/e2e-affected.mjs')
|
||||
const scope = readJsonScript('scripts/ci-scope.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('\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)
|
||||
console.log(
|
||||
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode}`,
|
||||
)
|
||||
if (scope.e2e.unmapped?.length > 0) {
|
||||
console.error(`[gate:ci] e2e unmapped src/ paths:\n ${scope.e2e.unmapped.join('\n ')}`)
|
||||
}
|
||||
|
||||
// --- Phase 1 (cheap) ---
|
||||
run('check-test-locations', 'node', ['scripts/check-test-locations.mjs'])
|
||||
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 === 'full') {
|
||||
if (scope.code.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)')
|
||||
} else {
|
||||
run('typecheck', 'pnpm', ['typecheck'])
|
||||
run('knip', 'pnpm', ['exec', 'knip'])
|
||||
run('check:cycles', 'pnpm', ['check:cycles'])
|
||||
}
|
||||
|
||||
if (scope.test.mode === 'full') {
|
||||
run('test:unit (full)', 'pnpm', ['test:unit'])
|
||||
} else if (testScope.mode === 'changed') {
|
||||
} else if (scope.test.mode === 'changed') {
|
||||
run('test:unit (changed)', 'pnpm', [
|
||||
'test:unit',
|
||||
'--',
|
||||
'--changed',
|
||||
testScope.base,
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
} else {
|
||||
console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)')
|
||||
}
|
||||
|
||||
if (testScope.mode !== 'none') {
|
||||
// --- Phase 2 (expensive) — only when phase 1 would have been green ---
|
||||
const needsDb =
|
||||
scope.test.mode !== 'none' || scope.build.mode !== 'none' || scope.e2e.mode !== 'none'
|
||||
if (needsDb) {
|
||||
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 (scope.test.mode !== 'none') {
|
||||
run('migrate', 'pnpm', ['migrate'], dbEnv)
|
||||
run('db:seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv)
|
||||
if (testScope.mode === 'full') {
|
||||
if (scope.test.mode === 'full') {
|
||||
run('test:int (full)', 'pnpm', ['test:int'])
|
||||
} else {
|
||||
run('test:int (changed)', 'pnpm', [
|
||||
'test:int',
|
||||
'--',
|
||||
'--changed',
|
||||
testScope.base,
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
}
|
||||
|
|
@ -117,9 +128,13 @@ const main = async () => {
|
|||
console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)')
|
||||
}
|
||||
|
||||
run('build', 'pnpm', ['build'], dbEnv)
|
||||
if (scope.build.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ build skipped (no build surface)')
|
||||
} else {
|
||||
run('build', 'pnpm', ['build'], dbEnv)
|
||||
}
|
||||
|
||||
if (e2eScope.mode === 'none') {
|
||||
if (scope.e2e.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ e2e skipped (no e2e blast radius)')
|
||||
} else {
|
||||
run('playwright install chromium', 'pnpm', ['exec', 'playwright', 'install', 'chromium'])
|
||||
|
|
@ -128,12 +143,12 @@ const main = async () => {
|
|||
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') {
|
||||
if (scope.e2e.mode === 'full') {
|
||||
run('test:e2e (full)', 'pnpm', ['test:e2e'], e2eEnv)
|
||||
} else {
|
||||
const specPaths = e2eScope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
const specPaths = scope.e2e.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
run(
|
||||
`test:e2e (selected: ${e2eScope.specs.join(', ')})`,
|
||||
`test:e2e (selected: ${scope.e2e.specs.join(', ')})`,
|
||||
'pnpm',
|
||||
['test:e2e', '--', ...specPaths],
|
||||
e2eEnv,
|
||||
|
|
@ -141,7 +156,7 @@ const main = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, affected scope)')
|
||||
console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, cascade + affected scope)')
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* The supervisor claims the issue BEFORE spawning (label flip + pool-worker
|
||||
* marker comment — the coordinated-claim anti-race), so the worker SKIPS the
|
||||
* claim step of work-issue and starts at step 1b. Everything else — gates, PR
|
||||
* base stage with Closes #N, auto-merge + CI watch, never promote — is the
|
||||
* standard skill contract restated so the worker cannot miss it.
|
||||
* base main with Closes #N, auto-merge + CI watch — is the standard skill
|
||||
* contract restated so the worker cannot miss it.
|
||||
*/
|
||||
|
||||
/** First docs/plans/*.md path in the issue body (the linked plan), if any. */
|
||||
|
|
@ -37,13 +37,12 @@ export const buildPoolWorkerPrompt = ({ issueNumber, issueTitle, issueId, planPa
|
|||
? `2. Plano: \`${planPath}\` — freshness audit enxuto (passo 3 da skill).`
|
||||
: '2. O body da Issue é a spec (não há plano linkado — avalie se ele basta para trabalhar).',
|
||||
'3. Execute as fases com os gates do repo: `pnpm gate:fast` antes do push (o pre-push roda `pnpm gate:push`). Comandos bare, nunca piped.',
|
||||
`4. PR obrigatoriamente com \`gh pr create --base stage\` e "Closes #${issueNumber}" no body.`,
|
||||
'5. `gh pr merge --auto --merge <PR>` e acompanhe `gh pr checks <PR> --watch` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.',
|
||||
`4. PR obrigatoriamente com \`gh pr create --base main\` e "Closes #${issueNumber}" no body.`,
|
||||
'5. `gh pr merge --auto --merge <PR>` e acompanhe `gh pr checks <PR> --watch --required` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.',
|
||||
'',
|
||||
'## Proibido',
|
||||
'',
|
||||
'- `pnpm agent:promote` — promote stage→main é humano.',
|
||||
'- `DATABASE_URL` de stage/prod ou `ALLOW_REMOTE_DB` — o setup local (`.cursor/cloud-setup.sh` + seed mínimo) cobre tudo.',
|
||||
'- `DATABASE_URL` de prod ou `ALLOW_REMOTE_DB` — o setup local (`.cursor/cloud-setup.sh` + seed mínimo) cobre tudo.',
|
||||
`- Editar outras Issues \`in-progress\` ou trabalhar fora da Issue #${issueNumber}.`,
|
||||
'',
|
||||
modelSlug
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* Pure helpers for `agent-promote`: pick the newest commit on stage (ahead of main)
|
||||
* that has a successful ci-stage run — not necessarily the current stage head.
|
||||
*/
|
||||
|
||||
/** Branch used when promoting a green commit behind a red stage head. */
|
||||
export const PROMOTE_GREEN_BRANCH = 'promote/last-green'
|
||||
|
||||
/**
|
||||
* @param {string[]} commitsAhead newest-first SHAs on origin/main..origin/stage
|
||||
* @param {ReadonlySet<string>} greenShas head SHAs with ci-stage success
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function findLastGreenPromoteSha(commitsAhead, greenShas) {
|
||||
for (const sha of commitsAhead) {
|
||||
if (greenShas.has(sha)) return sha
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ headSha: string; status: string; conclusion: string | null }>} stageRuns
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function greenCiStageHeadShas(stageRuns) {
|
||||
const green = new Set()
|
||||
for (const run of stageRuns) {
|
||||
if (run.status === 'completed' && run.conclusion === 'success') {
|
||||
green.add(run.headSha)
|
||||
}
|
||||
}
|
||||
return green
|
||||
}
|
||||
|
|
@ -56,7 +56,11 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
specs: ['campaignLeaderships'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'],
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/territorios`,
|
||||
'src/components/campaign/tour',
|
||||
'src/utilities/territory',
|
||||
],
|
||||
specs: ['campaignTerritories'],
|
||||
},
|
||||
{
|
||||
|
|
@ -83,4 +87,29 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
],
|
||||
specs: ['campaign-pwa', 'campaignWizardChrome'],
|
||||
},
|
||||
// Domains without a dedicated e2e family still wake campaign home smoke so
|
||||
// the affected classifier cannot return mode=none on an unmapped domain dir.
|
||||
{
|
||||
prefixes: [
|
||||
'src/components/campaign/advisor',
|
||||
'src/components/campaign/demand',
|
||||
'src/components/campaign/invite',
|
||||
'src/components/campaign/organization',
|
||||
'src/components/campaign/stateDeputy',
|
||||
'src/components/campaign/suggestion',
|
||||
'src/components/campaign/supporter',
|
||||
'src/components/campaign/votePledge',
|
||||
'src/components/campaign/auth',
|
||||
`${CAMPAIGN_APP}/acoes`,
|
||||
`${CAMPAIGN_APP}/apoiadores`,
|
||||
`${CAMPAIGN_APP}/assessores`,
|
||||
`${CAMPAIGN_APP}/contatos`,
|
||||
`${CAMPAIGN_APP}/demandas`,
|
||||
`${CAMPAIGN_APP}/dobradinhas`,
|
||||
`${CAMPAIGN_APP}/organizacoes`,
|
||||
`${CAMPAIGN_APP}/perfil`,
|
||||
`${CAMPAIGN_APP}/quadro`,
|
||||
],
|
||||
specs: ['campaignHomeActions'],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
/**
|
||||
* Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no
|
||||
* process — the CLI wrappers feed it the changed file list and it decides.
|
||||
* Unit-pinned in tests/unit/testAffected.unit.spec.ts and
|
||||
* Unit-pinned in tests/unit/testAffected.unit.spec.ts,
|
||||
* tests/unit/ciSkipInvariants.unit.spec.ts, and
|
||||
* tests/unit/e2eAffectedManifest.unit.spec.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paths whose blast radius is the whole app: schema, test harness, lockfile.
|
||||
* Any diff touching them runs the FULL suites — selection is unsafe there.
|
||||
* Paths whose blast radius is the whole app: schema, test harness, lockfile,
|
||||
* skip classifiers. Any diff touching them runs the FULL suites — selection
|
||||
* is unsafe there.
|
||||
*/
|
||||
export const HIGH_RISK_EXACT = new Set([
|
||||
'package.json',
|
||||
|
|
@ -20,6 +22,15 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'src/payload.config.ts',
|
||||
'scripts/seed-minimal.mjs',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/lib/e2e-affected-manifest.mjs',
|
||||
'scripts/test-affected.mjs',
|
||||
'scripts/e2e-affected.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/gate-ci.mjs',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
'tests/unit/testAffected.unit.spec.ts',
|
||||
'.env.test',
|
||||
])
|
||||
|
||||
|
|
@ -31,18 +42,105 @@ export const HIGH_RISK_PREFIXES = [
|
|||
'tests/e2e/fixtures/',
|
||||
]
|
||||
|
||||
function isHighRisk(path) {
|
||||
/** Canonical vitest/playwright globs — keep in sync with config files. */
|
||||
export const CANONICAL_UNIT_INCLUDE = 'tests/unit/**/*.unit.spec.{ts,tsx}'
|
||||
export const CANONICAL_INT_INCLUDE = 'tests/int/**/*.int.spec.ts'
|
||||
export const CANONICAL_E2E_TEST_DIR = 'tests/e2e'
|
||||
export const CANONICAL_E2E_SPEC_SUFFIX = '.e2e.spec.ts'
|
||||
|
||||
const CODE_CONFIG_EXACT = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'tsconfig.json',
|
||||
'tsconfig.tsbuildinfo',
|
||||
'eslint.config.mjs',
|
||||
'eslint.config.js',
|
||||
'knip.json',
|
||||
'knip.ts',
|
||||
'knip.config.ts',
|
||||
'.eslintrc.cjs',
|
||||
'.eslintrc.js',
|
||||
])
|
||||
|
||||
const CODE_CONFIG_PREFIXES = ['tsconfig']
|
||||
|
||||
const BUILD_SURFACE_EXACT = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'next.config.ts',
|
||||
'src/payload.config.ts',
|
||||
'src/payload-types.ts',
|
||||
'tsconfig.json',
|
||||
])
|
||||
|
||||
const BUILD_SURFACE_PREFIXES = [
|
||||
'src/',
|
||||
'public/',
|
||||
'src/migrations/',
|
||||
'src/collections/',
|
||||
'src/globals/',
|
||||
'tsconfig',
|
||||
]
|
||||
|
||||
const SPEC_FILE_RE = /\.(?:spec|test)\.(?:ts|tsx)$/
|
||||
const UNIT_SPEC_RE = /^tests\/unit\/.+\.unit\.spec\.(?:ts|tsx)$/
|
||||
const INT_SPEC_RE = /^tests\/int\/.+\.int\.spec\.ts$/
|
||||
const E2E_SPEC_RE = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
|
||||
|
||||
/** Cross-cutting dirs under campaign components (no dedicated e2e family). */
|
||||
export const E2E_MANIFEST_DOMAIN_EXEMPT = new Set(['shared'])
|
||||
|
||||
export function isHighRisk(path) {
|
||||
return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
export function isTestPath(path) {
|
||||
return path.startsWith('tests/')
|
||||
}
|
||||
|
||||
export function isSrcPath(path) {
|
||||
return path.startsWith('src/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that affect typecheck / knip / madge (and therefore those PR jobs).
|
||||
*/
|
||||
export function isCodePath(path) {
|
||||
if (isSrcPath(path) || isTestPath(path)) return true
|
||||
if (CODE_CONFIG_EXACT.has(path)) return true
|
||||
if (CODE_CONFIG_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}.`))) {
|
||||
return true
|
||||
}
|
||||
if (path.startsWith('eslint') || path.includes('eslint.config')) return true
|
||||
if (path.startsWith('knip')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that affect `pnpm build` (Next + Payload surface).
|
||||
*/
|
||||
export function isBuildPath(path) {
|
||||
if (BUILD_SURFACE_EXACT.has(path)) return true
|
||||
if (isSrcPath(path) || path.startsWith('public/')) return true
|
||||
if (path.startsWith('next.config')) return true
|
||||
if (BUILD_SURFACE_PREFIXES.some((prefix) => path.startsWith(prefix))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isCanonicalSpecPath(path) {
|
||||
return UNIT_SPEC_RE.test(path) || INT_SPEC_RE.test(path) || E2E_SPEC_RE.test(path)
|
||||
}
|
||||
|
||||
export function isMisplacedSpecPath(path) {
|
||||
return SPEC_FILE_RE.test(path) && !isCanonicalSpecPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide vitest scope for a PR diff.
|
||||
* @param {{ path: string, status: string }[]} files git diff --name-status rows.
|
||||
* @returns {{ mode: 'full' | 'changed' | 'none', reason: string }}
|
||||
* full — high-risk path or new src file (nothing imports it yet, so
|
||||
* --changed cannot find it; silent green otherwise);
|
||||
* changed — vitest --changed <base> is meaningful;
|
||||
* none — no src/tests touched (docs-only PR); skip the jobs.
|
||||
*/
|
||||
export function classifyTestScope(files) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
|
|
@ -57,6 +155,31 @@ export function classifyTestScope(files) {
|
|||
return { mode: 'none', reason: 'no src/ or tests/ changes' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether typecheck / knip / cycles should run.
|
||||
* @returns {{ mode: 'code' | 'none', reason: string }}
|
||||
*/
|
||||
export function classifyStaticScope(files) {
|
||||
if (files.some(({ path }) => isCodePath(path))) {
|
||||
return { mode: 'code', reason: 'diff touches src/tests or type/graph config' }
|
||||
}
|
||||
return { mode: 'none', reason: 'no code/type/graph surface changes' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the PR build job should run.
|
||||
* @returns {{ mode: 'build' | 'none', reason: string }}
|
||||
*/
|
||||
export function classifyBuildScope(files) {
|
||||
if (files.some(({ path }) => isHighRisk(path) && isBuildPath(path))) {
|
||||
return { mode: 'build', reason: 'diff touches a high-risk build surface path' }
|
||||
}
|
||||
if (files.some(({ path }) => isBuildPath(path))) {
|
||||
return { mode: 'build', reason: 'diff touches build surface' }
|
||||
}
|
||||
return { mode: 'none', reason: 'no build surface changes' }
|
||||
}
|
||||
|
||||
const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
|
||||
|
||||
/**
|
||||
|
|
@ -65,9 +188,6 @@ const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
|
|||
* @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names.
|
||||
* @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string,
|
||||
* unmapped: string[] }}
|
||||
* `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/
|
||||
* files with no manifest prefix — logged in CI, never a failure (e2e is a
|
||||
* thin layer over int; the gap is for a human to judge).
|
||||
*/
|
||||
export function selectE2eSpecs(files, manifest) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
|
|
@ -113,3 +233,50 @@ export function selectE2eSpecs(files, manifest) {
|
|||
unmapped,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a list of repo-relative paths and return misplaced spec/test files.
|
||||
* @param {string[]} paths
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findMisplacedSpecPaths(paths) {
|
||||
return paths.filter(isMisplacedSpecPath).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain folders that must appear in the e2e affected manifest (or be exempt).
|
||||
* @param {string[]} componentDirs immediate children of src/components/campaign
|
||||
* @param {string[]} appRouteDirs immediate children of src/app/(campaign)/campanha/(app)
|
||||
* @param {{ prefixes: string[] }[]} manifest
|
||||
* @param {Set<string>} [exempt]
|
||||
* @returns {{ missing: string[], covered: string[] }}
|
||||
*/
|
||||
export function findUncoveredE2eDomainPrefixes(
|
||||
componentDirs,
|
||||
appRouteDirs,
|
||||
manifest,
|
||||
exempt = E2E_MANIFEST_DOMAIN_EXEMPT,
|
||||
) {
|
||||
const prefixes = manifest.flatMap((entry) => entry.prefixes)
|
||||
const required = []
|
||||
|
||||
for (const dir of componentDirs) {
|
||||
if (exempt.has(dir)) continue
|
||||
required.push(`src/components/campaign/${dir}`)
|
||||
}
|
||||
for (const dir of appRouteDirs) {
|
||||
if (dir.includes('.')) continue // files like page.tsx / error.tsx
|
||||
required.push(`src/app/(campaign)/campanha/(app)/${dir}`)
|
||||
}
|
||||
|
||||
const missing = []
|
||||
const covered = []
|
||||
for (const path of required) {
|
||||
const hit = prefixes.some(
|
||||
(prefix) => prefix === path || prefix.startsWith(`${path}/`) || path.startsWith(prefix),
|
||||
)
|
||||
if (hit) covered.push(path)
|
||||
else missing.push(path)
|
||||
}
|
||||
return { missing: missing.sort(), covered: covered.sort() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
* mode=changed → run `vitest --changed <base>`
|
||||
* mode=none → nothing to run (docs-only diff); CI skips the step
|
||||
*
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally. No DB, no
|
||||
* Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { classifyTestScope } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# Vercel "Ignored Build Step": exit 0 = skip deployment, exit 1 = build.
|
||||
# Agent feature branches must not queue preview builds (Hobby = 1 concurrent build).
|
||||
# stage/main always build (stage preview + prod).
|
||||
# Production deploys are gated by GitHub Actions (`ci.yml` → `vercel deploy --prod`).
|
||||
# Git pushes must NOT trigger Vercel builds (Hobby concurrency + ungated deploy race).
|
||||
set -euo pipefail
|
||||
|
||||
ref="${VERCEL_GIT_COMMIT_REF:-}"
|
||||
|
||||
if [ "$ref" = "main" ] || [ "$ref" = "stage" ]; then
|
||||
exit 1
|
||||
echo "skip: $ref — production deploy is Actions-gated (ci.yml), not Vercel Git"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ref" == agent/* ]] || [[ "$ref" == cursor/* ]]; then
|
||||
|
|
@ -15,4 +16,5 @@ if [[ "$ref" == agent/* ]] || [[ "$ref" == cursor/* ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
echo "skip: non-canonical branch ($ref) — no Vercel Git builds"
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const loadNotificationBellData = async (): Promise<NotificationBellData |
|
|||
if (!user) return null
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const unreadCount = await countUnreadNotifications(payload, user.id)
|
||||
const unreadCount = await countUnreadNotifications(payload, user)
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
|
|
@ -56,7 +56,7 @@ export const listCampaignNotifications = async (): Promise<NotificationListItem[
|
|||
if (!user) return []
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
return loadNotificationList(payload, user.id)
|
||||
return loadNotificationList(payload, user)
|
||||
}
|
||||
|
||||
export const markAllCampaignNotificationsRead = async () =>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import { GlobalConfig } from 'payload'
|
||||
|
||||
|
|
@ -13,7 +14,8 @@ export const HomePage: GlobalConfig = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
// Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*.
|
||||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import { GlobalConfig } from 'payload'
|
||||
|
||||
|
|
@ -13,7 +14,8 @@ export const Metadata: GlobalConfig = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
// Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*.
|
||||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
|
|
@ -18,7 +19,9 @@ export const PrivacyPolicy: GlobalConfig = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
// Legal text: admin-only like the other site globals (Pass 4) — campaign
|
||||
// JWTs reach /api/*.
|
||||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateGlobal } from '@/utilities/globals'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
|
|
@ -13,7 +14,9 @@ export const SiteSettings: GlobalConfig = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
// Site-wide config: campaign JWTs authenticate against /api/*, so "any
|
||||
// authenticated user" here was a write path into the public site (Pass 4).
|
||||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import 'server-only'
|
||||
|
||||
/** Trusted server reads in this module use admin bypass (`overrideAccess: true`). */
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type {
|
||||
NotificationListItem,
|
||||
NotificationPayload,
|
||||
NotificationType,
|
||||
} from '@/lib/notificationContract'
|
||||
import type { Notification } from '@/payload-types'
|
||||
import type { CampaignUser, Notification } from '@/payload-types'
|
||||
|
||||
export type { NotificationListItem } from '@/lib/notificationContract'
|
||||
|
||||
|
|
@ -33,33 +33,40 @@ const toNotificationListItem = (doc: Notification): NotificationListItem | null
|
|||
}
|
||||
}
|
||||
|
||||
// Reads go through collection access (`canReadOwnNotifications` scopes to the
|
||||
// recipient) with the explicit recipient filter as a second fence: the actor
|
||||
// is the only thing these loaders accept, so a caller cannot ask for someone
|
||||
// else's inbox. No admin bypass — that is what the create/update paths are
|
||||
// for, and they live in the domain hooks/actions with their own justifications.
|
||||
export const countUnreadNotifications = async (
|
||||
payload: import('payload').Payload,
|
||||
recipientID: number,
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<number> => {
|
||||
const result = await payload.count({
|
||||
collection: 'notification',
|
||||
where: {
|
||||
and: [{ recipient: { equals: recipientID } }, { readAt: { exists: false } }],
|
||||
and: [{ recipient: { equals: user.id } }, { readAt: { exists: false } }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.totalDocs
|
||||
}
|
||||
|
||||
export const loadNotificationList = async (
|
||||
payload: import('payload').Payload,
|
||||
recipientID: number,
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
limit = 40,
|
||||
): Promise<NotificationListItem[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'notification',
|
||||
where: { recipient: { equals: recipientID } },
|
||||
where: { recipient: { equals: user.id } },
|
||||
depth: 0,
|
||||
limit,
|
||||
sort: '-createdAt',
|
||||
overrideAccess: true,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs
|
||||
|
|
|
|||
|
|
@ -82,17 +82,22 @@ test.describe('Início — busca global (B47)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
// Claimed, never hardcoded: parallel specs sharing one seeded municipality
|
||||
// raced each other's mutations on it (miss #73).
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
const search = page.getByLabel('Buscar na campanha')
|
||||
await search.fill('Cairu')
|
||||
await search.fill(municipality.name)
|
||||
|
||||
const group = page.getByRole('region', { name: 'Resultados da busca' })
|
||||
await expect(group.getByRole('heading', { name: 'Municípios' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await group.getByRole('link', { name: /Cairu/i }).click()
|
||||
await page.waitForURL(/\/campanha\/municipios\/cairu/)
|
||||
// Disambiguate by href, not by name: catalog names are not prefix-unique
|
||||
// ("Conde" also matches "Condeúba" — the unanchored-regex lesson).
|
||||
await group.locator(`a[href$="/campanha/municipios/${municipality.slug}"]`).click()
|
||||
await page.waitForURL(new RegExp(`^https?://[^/]+/campanha/municipios/${municipality.slug}$`))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -158,20 +163,28 @@ test.describe('Wizard — busca município (B60)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos')
|
||||
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
|
||||
|
||||
const search = page.getByLabel('Buscar município')
|
||||
await search.fill('Cairu')
|
||||
await search.fill(municipality.name)
|
||||
|
||||
const results = page.getByRole('region', { name: 'Resultados da busca' })
|
||||
await expect(results.getByRole('link', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
|
||||
await results.getByRole('link', { name: /Cairu/i }).click()
|
||||
const hit = results.locator(`a[href*="municipio=${municipality.slug}"]`)
|
||||
await expect(hit).toBeVisible({ timeout: 15000 })
|
||||
await hit.click()
|
||||
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
|
||||
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
|
||||
await page.waitForURL(
|
||||
new RegExp(
|
||||
`^https?://[^/]+/campanha/acoes/atualizar-votos\\?municipio=${municipality.slug}$`,
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.getByLabel(new RegExp(`^Município em atualização: ${municipality.name}`, 'i')),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -188,9 +201,10 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}`)
|
||||
|
||||
const step = wizardVoteStep(page)
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
|
|
@ -217,9 +231,10 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
@ -239,11 +254,18 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu&cenario=pessimistic')
|
||||
await page.goto(
|
||||
`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}&cenario=pessimistic`,
|
||||
)
|
||||
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu$/)
|
||||
await page.waitForURL(
|
||||
new RegExp(
|
||||
`^https?://[^/]+/campanha/acoes/atualizar-votos\\?municipio=${municipality.slug}$`,
|
||||
),
|
||||
)
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -256,10 +278,11 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const suffix = Date.now().toString().slice(-8).padStart(8, '0')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-lideranca?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/atualizar-lideranca?municipio=${municipality.slug}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
@ -293,8 +316,10 @@ test.describe('Wizard — registrar sinal (B63)', () => {
|
|||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
@ -319,9 +344,10 @@ test.describe('Wizard — registrar sinal (B63)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
@ -341,9 +367,12 @@ test.describe('Wizard — registrar sinal (B63)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu&entry=update-votes')
|
||||
await page.goto(
|
||||
`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}&entry=update-votes`,
|
||||
)
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
|
|||
|
|
@ -382,7 +382,8 @@ test.describe('Municípios — cards no celular (B42)', () => {
|
|||
await expect(page.getByRole('link', { name: 'Abrir município' })).toHaveCount(0)
|
||||
|
||||
await card.getByRole('button', { name: 'Editar tendência política' }).click()
|
||||
const drawer = page.locator('[data-slot="drawer-popup"]')
|
||||
// B79 keeps CampaignQuickActionsDrawer mounted (modal=false); scope to the trend dialog.
|
||||
const drawer = page.getByRole('dialog', { name: 'Editar tendência' })
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(listURL)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { BrowserContext, Page } from '@playwright/test'
|
|||
|
||||
import { CAMPAIGN_SESSION_TTL_LONG } from '../../src/lib/campaignSessionTtl.js'
|
||||
import { CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY } from '../../src/utilities/campaignBiometricsPrompt.js'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import { expect, expectCampaignBiometricsReady, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* Biometric login (B40) end to end, against a **virtual authenticator** driven
|
||||
|
|
@ -68,8 +68,10 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|||
await addVirtualAuthenticator(context, page)
|
||||
|
||||
// 1. Enrollment is authenticated: the passkey card only exists behind a
|
||||
// password login.
|
||||
// password login. The platform answer is gated BEFORE the profile island
|
||||
// runs its one-shot probe (miss #53).
|
||||
await campaign.login(page, email, password)
|
||||
await expectCampaignBiometricsReady(page)
|
||||
await page.goto('/campanha/perfil')
|
||||
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
|
||||
|
|
@ -86,6 +88,7 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|||
// the "remember me" opt-in, so there is no checkbox in this path.
|
||||
await context.clearCookies()
|
||||
await page.goto('/campanha/login')
|
||||
await expectCampaignBiometricsReady(page)
|
||||
const biometricButton = page.getByRole('button', { name: 'Entrar com digital ou Face ID' })
|
||||
await expect(biometricButton).toBeVisible()
|
||||
|
||||
|
|
|
|||
|
|
@ -35,9 +35,12 @@ test.describe('Wizard — header mobile (B75)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
// Claimed, never hardcoded: parallel specs sharing one seeded municipality
|
||||
// raced each other's mutations on it (miss #73).
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
|
||||
await page.goto(`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
|
|
@ -45,7 +48,9 @@ test.describe('Wizard — header mobile (B75)', () => {
|
|||
|
||||
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
|
||||
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
|
||||
await expect(topBar.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
|
||||
await expect(
|
||||
topBar.getByLabel(new RegExp(`^Município em atualização: ${municipality.name}`, 'i')),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Voltar/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Voltar/ }).click()
|
||||
|
|
|
|||
|
|
@ -474,6 +474,25 @@ export const checkRadixWhenHydrated = async (page: Page, label: string) => {
|
|||
}).toPass({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Miss #53: the app's biometrics probe is ONE-SHOT per island mount
|
||||
* (`useCampaignBiometricsAvailable`) — if
|
||||
* `isUserVerifyingPlatformAuthenticatorAvailable()` resolves before the CDP
|
||||
* virtual authenticator is ready, the enrollment form / biometric button
|
||||
* never mount and no amount of waiting flips them. Gate the ceremony on the
|
||||
* platform answer BEFORE navigating into the surface whose island probes it,
|
||||
* so the race is decided before the app even asks. Requires a loaded page
|
||||
* (secure context) — call after `campaign.login` / `page.goto`.
|
||||
*/
|
||||
export const expectCampaignBiometricsReady = async (page: Page): Promise<void> => {
|
||||
await expect(async () => {
|
||||
const available = await page.evaluate(
|
||||
() => window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable?.() ?? false,
|
||||
)
|
||||
expect(available).toBe(true)
|
||||
}).toPass({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
type CampaignE2EFixture = {
|
||||
baseURL: string
|
||||
fixtures: CampaignE2EOwnership
|
||||
|
|
|
|||
|
|
@ -24,18 +24,6 @@ export function assertTestDatabase(databaseUrl: string | undefined): void {
|
|||
const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, ''))
|
||||
const localHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
||||
|
||||
// Documented stage escape hatch: ONLY the ci-stage workflow (GitHub
|
||||
// Environment `stage`, STAGE_DATABASE_URL secret) may point the int suite at
|
||||
// the stage Neon snapshot, and it sets this flag explicitly. Agents and local
|
||||
// runs never set it — the default stays the exact local teqo_test.
|
||||
if (process.env.ALLOW_STAGE_TEST_DB === 'true') {
|
||||
if (parsedUrl.protocol === 'postgresql:' && databaseName.length > 0) return
|
||||
throw new Error(
|
||||
'ALLOW_STAGE_TEST_DB is set but DATABASE_URL is not a postgresql: connection string ' +
|
||||
`with a database name (got "${parsedUrl.protocol || '(unknown)'}").`,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
parsedUrl.protocol !== 'postgresql:' ||
|
||||
databaseName !== 'teqo_test' ||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { getPayload, type Payload } from 'payload'
|
|||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
countUnreadNotifications,
|
||||
loadNotificationList,
|
||||
} from '@/utilities/notification/notificationList'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
|
|
@ -69,6 +73,42 @@ describe('notification access', () => {
|
|||
|
||||
expect(visible.docs.map((doc) => doc.id)).toEqual([own.id])
|
||||
})
|
||||
|
||||
it('reads the bell loaders through owner-scoped access (no bypass)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const [owner, other] = await Promise.all([
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
])
|
||||
|
||||
const own = await payload.create({
|
||||
collection: 'notification',
|
||||
data: {
|
||||
recipient: owner.id,
|
||||
type: 'municipality_update',
|
||||
payload: { title: 'Sino — própria', detail: 'Detalhe', href: '/campanha' },
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.create({
|
||||
collection: 'notification',
|
||||
data: {
|
||||
recipient: other.id,
|
||||
type: 'new_supporter',
|
||||
payload: { title: 'Sino — alheia', detail: 'Detalhe', href: '/campanha' },
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
// The loaders take the actor, not a recipientID — a caller cannot ask for
|
||||
// someone else's inbox (Pass 4: the previous recipientID+bypass shape
|
||||
// trusted every call site).
|
||||
await expect(loadNotificationList(payload, owner)).resolves.toEqual([
|
||||
expect.objectContaining({ id: own.id }),
|
||||
])
|
||||
await expect(countUnreadNotifications(payload, owner)).resolves.toBe(1)
|
||||
await expect(countUnreadNotifications(payload, other)).resolves.toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushSubscription access', () => {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/l
|
|||
import { Media } from '@/collections/Media'
|
||||
import { Post } from '@/collections/Post'
|
||||
import { Tag } from '@/collections/Tag'
|
||||
import { HomePage } from '@/globals/HomePage'
|
||||
import { Metadata } from '@/globals/Metadata'
|
||||
import { PrivacyPolicy } from '@/globals/PrivacyPolicy'
|
||||
import { SiteSettings } from '@/globals/SiteSettings'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import config from '@/payload.config'
|
||||
import { canManagePublishedContent } from '@/utilities/access/shared'
|
||||
import { canManagePublishedContent, payloadAdminOnly } from '@/utilities/access/shared'
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
|
@ -244,6 +248,75 @@ describe('CMS/PII collection access lockdown', () => {
|
|||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The globals sibling of the collection lockdown above: until Pass 4 the four
|
||||
* public globals spelled `update` as "any authenticated user", and campaign
|
||||
* JWTs authenticate against `/api/*` — a campaign session (leader included)
|
||||
* could PATCH the public site's settings, home, metadata and privacy policy.
|
||||
* The allow path is asserted on the access function itself because a real
|
||||
* `updateGlobal` fires the afterChange `revalidateTag` outside Next.
|
||||
*/
|
||||
describe('public globals update lockdown', () => {
|
||||
const publicGlobals = [
|
||||
{ config: SiteSettings, slug: 'site-settings' },
|
||||
{ config: HomePage, slug: 'home' },
|
||||
{ config: Metadata, slug: 'metadata' },
|
||||
{ config: PrivacyPolicy, slug: 'privacy-policy' },
|
||||
] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('spells update as payloadAdminOnly on every public global', () => {
|
||||
for (const { config: globalConfig } of publicGlobals) {
|
||||
expect(globalConfig.access?.update).toBe(payloadAdminOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(publicGlobals)('denies campaign users update on global %s', async ({ slug }) => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
||||
await expect(
|
||||
payload.updateGlobal({
|
||||
slug,
|
||||
data: {},
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it.each(publicGlobals)('denies editors update on global %s', async ({ slug }) => {
|
||||
const editor = await campaignFixtures().createEditorUser()
|
||||
|
||||
await expect(
|
||||
payload.updateGlobal({
|
||||
slug,
|
||||
data: {},
|
||||
user: editor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it('lets payload admins update through the access function', async () => {
|
||||
const admin = await campaignFixtures().createAdminUser()
|
||||
|
||||
for (const { config: globalConfig } of publicGlobals) {
|
||||
expect(globalConfig.access?.update?.({ req: stub<PayloadRequest>({ user: admin }) })).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(publicGlobals)('keeps anonymous read access on global %s', async ({ slug }) => {
|
||||
// Never-saved globals resolve with field defaults (no id) — the pin is
|
||||
// that anonymous read is not denied, not the document shape.
|
||||
await expect(payload.findGlobal({ slug, overrideAccess: false })).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('leader supporter creation scope', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ 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 otherQuery = distinctWordStartQuery(other.name, administered.name)
|
||||
|
|
|
|||
|
|
@ -68,11 +68,13 @@ describe('searchHomeStateDeputies (B52)', () => {
|
|||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy({ name: 'Carteira Dobradinha' })
|
||||
const cairu = await fixtures.getMunicipality('cairu')
|
||||
const feira = await fixtures.getMunicipality('feira-de-santana')
|
||||
// Allocated, never pinned slugs: parallel specs mutating the same seeded
|
||||
// municipality deadlocked municipality_rels (miss #73).
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = await fixtures.getMunicipality()
|
||||
await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [cairu.id, feira.id],
|
||||
municipalityIds: [first.id, second.id],
|
||||
assigned: true,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getPayload } from 'payload'
|
|||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
|
||||
import config from '@/payload.config'
|
||||
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
|
||||
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
|
|
@ -287,19 +288,27 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const redutoEntry = municipalityCatalog.find(
|
||||
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'reduto',
|
||||
)!
|
||||
const marginalEntry = municipalityCatalog.find(
|
||||
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'marginal',
|
||||
)!
|
||||
const reduto = await fixtures.getMunicipality(redutoEntry.slug)
|
||||
const marginal = await fixtures.getMunicipality(marginalEntry.slug)
|
||||
// Two allocated municipalities with DISTINCT territorial classes: the
|
||||
// behavior pinned here (class filter narrows, totals stay honest, classe
|
||||
// sort follows the weight table) does not care WHICH classes they are —
|
||||
// and pinning fixed catalog slugs raced parallel specs mutating the same
|
||||
// seeded rows (deadlock on municipality_rels, miss #73).
|
||||
const first = await fixtures.getMunicipality()
|
||||
const firstClass = computeMunicipalityTerritorialClass(first.slug).class
|
||||
let second = await fixtures.getMunicipality()
|
||||
let secondClass = computeMunicipalityTerritorialClass(second.slug).class
|
||||
for (let attempt = 0; secondClass === firstClass && attempt < 20; attempt++) {
|
||||
second = await fixtures.getMunicipality()
|
||||
secondClass = computeMunicipalityTerritorialClass(second.slug).class
|
||||
}
|
||||
if (secondClass === firstClass) {
|
||||
throw new Error('could not allocate municipalities of two distinct classes')
|
||||
}
|
||||
|
||||
const marker = `e10-classe-${Date.now()}`
|
||||
for (const [municipality, suffix] of [
|
||||
[reduto, 'reduto'],
|
||||
[marginal, 'marginal'],
|
||||
[first, 'filtrada'],
|
||||
[second, 'fora'],
|
||||
] as const) {
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
|
|
@ -313,29 +322,39 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
|
||||
const filtered = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
class: 'reduto',
|
||||
class: firstClass,
|
||||
})
|
||||
expect(filtered.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
|
||||
expect(filtered.municipalities.map((row) => row.slug)).toEqual([first.slug])
|
||||
expect(filtered.totalDocs).toBe(1)
|
||||
expect(filtered.overview?.municipalityCount).toBe(1)
|
||||
expect(filtered.municipalities[0]!.territorialClass).toBe('reduto')
|
||||
expect(filtered.municipalities[0]!.territorialClass).toBe(firstClass)
|
||||
expect(filtered.municipalities[0]!.territorialClassFactors.length).toBeGreaterThan(0)
|
||||
|
||||
// A native sort key would otherwise let Payload paginate before the class
|
||||
// filter runs, which would hand back a short page with an inflated total.
|
||||
const byName = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
class: 'reduto',
|
||||
class: firstClass,
|
||||
sort: 'name',
|
||||
})
|
||||
expect(byName.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
|
||||
expect(byName.municipalities.map((row) => row.slug)).toEqual([first.slug])
|
||||
expect(byName.totalDocs).toBe(1)
|
||||
|
||||
const sorted = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'classe',
|
||||
})
|
||||
expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug])
|
||||
// `classe` sorts strongest class first (reduto 4 … marginal 1; every
|
||||
// catalog row classes into a weighted class, so no null weight exists).
|
||||
const expectedOrder = [first, second]
|
||||
.map((municipality) => ({
|
||||
slug: municipality.slug,
|
||||
weight:
|
||||
territorialClassSortWeight[computeMunicipalityTerritorialClass(municipality.slug).class],
|
||||
}))
|
||||
.sort((left, right) => (right.weight ?? 0) - (left.weight ?? 0))
|
||||
.map((entry) => entry.slug)
|
||||
expect(sorted.municipalities.map((row) => row.slug)).toEqual(expectedOrder)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -348,9 +367,9 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const low = await fixtures.getMunicipality(municipalityCatalog[0]!.slug)
|
||||
const high = await fixtures.getMunicipality(municipalityCatalog[1]!.slug)
|
||||
const none = await fixtures.getMunicipality(municipalityCatalog[2]!.slug)
|
||||
const low = await fixtures.getMunicipality()
|
||||
const high = await fixtures.getMunicipality()
|
||||
const none = await fixtures.getMunicipality()
|
||||
|
||||
const marker = `e14-nivel-${Date.now()}`
|
||||
for (const [municipality, suffix, engagementLevel] of [
|
||||
|
|
@ -446,9 +465,7 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
data: { priority: 'alta' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const covered = await fixtures.getMunicipality(
|
||||
municipalityCatalog.find((entry) => entry.slug !== priorityMunicipality.slug)!.slug,
|
||||
)
|
||||
const covered = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(covered.id, [advisor.id])
|
||||
|
||||
// "Prioritária" lives in the Município popover, so it must not hide the
|
||||
|
|
|
|||
|
|
@ -19,15 +19,14 @@ describe('buildPoolWorkerPrompt', () => {
|
|||
expect(prompt).toContain('in-progress')
|
||||
})
|
||||
|
||||
it('pins the PR contract: base stage, Closes #N, auto-merge, CI watch', () => {
|
||||
expect(prompt).toContain('gh pr create --base stage')
|
||||
it('pins the PR contract: base main, Closes #N, auto-merge, CI watch', () => {
|
||||
expect(prompt).toContain('gh pr create --base main')
|
||||
expect(prompt).toContain('Closes #42')
|
||||
expect(prompt).toContain('gh pr merge --auto --merge')
|
||||
expect(prompt).toContain('gh pr checks <PR> --watch')
|
||||
})
|
||||
|
||||
it('restates the hard prohibitions (promote humano, sem DB remota, escopo da Issue)', () => {
|
||||
expect(prompt).toContain('pnpm agent:promote')
|
||||
it('restates the hard prohibitions (sem DB remota, escopo da Issue)', () => {
|
||||
expect(prompt).toContain('DATABASE_URL')
|
||||
expect(prompt).toContain('ALLOW_REMOTE_DB')
|
||||
expect(prompt).toContain('trabalhar fora da Issue #42')
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
findLastGreenPromoteSha,
|
||||
greenCiStageHeadShas,
|
||||
} from '../../scripts/lib/agent-promote-target.mjs'
|
||||
|
||||
describe('greenCiStageHeadShas', () => {
|
||||
it('collects only completed successes', () => {
|
||||
const green = greenCiStageHeadShas([
|
||||
{ headSha: 'aaa', status: 'completed', conclusion: 'success' },
|
||||
{ headSha: 'bbb', status: 'completed', conclusion: 'failure' },
|
||||
{ headSha: 'ccc', status: 'in_progress', conclusion: null },
|
||||
])
|
||||
expect([...green]).toEqual(['aaa'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLastGreenPromoteSha', () => {
|
||||
it('returns the newest green commit ahead of main', () => {
|
||||
const green = new Set(['bbb', 'ddd'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb', 'aaa'], green)).toBe('bbb')
|
||||
})
|
||||
|
||||
it('returns stage head when it is green', () => {
|
||||
const green = new Set(['ccc'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBe('ccc')
|
||||
})
|
||||
|
||||
it('returns null when nothing ahead is green', () => {
|
||||
const green = new Set(['zzz'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is nothing ahead of main', () => {
|
||||
expect(findLastGreenPromoteSha([], new Set(['aaa']))).toBeNull()
|
||||
})
|
||||
})
|
||||
112
tests/unit/ciSkipInvariants.unit.spec.ts
Normal file
112
tests/unit/ciSkipInvariants.unit.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { checkTestLocations } from '../../scripts/check-test-locations.mjs'
|
||||
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
CANONICAL_E2E_SPEC_SUFFIX,
|
||||
CANONICAL_E2E_TEST_DIR,
|
||||
CANONICAL_INT_INCLUDE,
|
||||
CANONICAL_UNIT_INCLUDE,
|
||||
E2E_MANIFEST_DOMAIN_EXEMPT,
|
||||
findMisplacedSpecPaths,
|
||||
findUncoveredE2eDomainPrefixes,
|
||||
HIGH_RISK_EXACT,
|
||||
isBuildPath,
|
||||
isCanonicalSpecPath,
|
||||
isCodePath,
|
||||
isHighRisk,
|
||||
isMisplacedSpecPath,
|
||||
isSrcPath,
|
||||
isTestPath,
|
||||
} from '../../scripts/lib/test-affected-core.mjs'
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
||||
|
||||
describe('ciSkipInvariants', () => {
|
||||
it('exports shared path predicates used by classifiers and the lint guard', () => {
|
||||
expect(isSrcPath('src/lib/x.ts')).toBe(true)
|
||||
expect(isTestPath('tests/unit/x.unit.spec.ts')).toBe(true)
|
||||
expect(isCodePath('tsconfig.json')).toBe(true)
|
||||
expect(isBuildPath('public/favicon.ico')).toBe(true)
|
||||
expect(isHighRisk('src/migrations/x.ts')).toBe(true)
|
||||
expect(E2E_MANIFEST_DOMAIN_EXEMPT.has('shared')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects misplaced *.spec|*.test files outside the three canonical trees', () => {
|
||||
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.ts')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.tsx')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/int/foo.int.spec.ts')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/e2e/foo.e2e.spec.ts')).toBe(true)
|
||||
|
||||
for (const path of [
|
||||
'src/utilities/foo.spec.ts',
|
||||
'scripts/bar.test.ts',
|
||||
'tests/unit/wrong.spec.ts',
|
||||
'tests/int/wrong.spec.ts',
|
||||
'tests/e2e/nested/foo.e2e.spec.ts',
|
||||
'foo.test.tsx',
|
||||
]) {
|
||||
expect(isMisplacedSpecPath(path), path).toBe(true)
|
||||
}
|
||||
|
||||
expect(findMisplacedSpecPaths(['tests/unit/ok.unit.spec.ts', 'src/x.spec.ts'])).toEqual([
|
||||
'src/x.spec.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('repo walk finds no misplaced specs (same check as the lint job)', () => {
|
||||
expect(checkTestLocations(repoRoot)).toEqual([])
|
||||
})
|
||||
|
||||
it('vitest/playwright harness globs match the canonical constants', () => {
|
||||
const unitConfig = readFileSync(join(repoRoot, 'vitest.unit.config.mts'), 'utf8')
|
||||
const intConfig = readFileSync(join(repoRoot, 'vitest.config.mts'), 'utf8')
|
||||
const playwrightConfig = readFileSync(join(repoRoot, 'playwright.config.ts'), 'utf8')
|
||||
|
||||
expect(unitConfig).toContain(`'${CANONICAL_UNIT_INCLUDE}'`)
|
||||
expect(intConfig).toContain(`'${CANONICAL_INT_INCLUDE}'`)
|
||||
expect(playwrightConfig).toContain(`'./${CANONICAL_E2E_TEST_DIR}'`)
|
||||
expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts')
|
||||
})
|
||||
|
||||
it('skip classifiers and invariant specs are high-risk (force full suite)', () => {
|
||||
for (const path of [
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/test-affected.mjs',
|
||||
'scripts/e2e-affected.mjs',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
'tests/unit/testAffected.unit.spec.ts',
|
||||
]) {
|
||||
expect(HIGH_RISK_EXACT.has(path), path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('every campaign domain dir is covered by the e2e affected manifest', () => {
|
||||
const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
|
||||
const appRouteDirs = readdirSync(join(repoRoot, 'src/app/(campaign)/campanha/(app)'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
|
||||
const { missing } = findUncoveredE2eDomainPrefixes(
|
||||
componentDirs,
|
||||
appRouteDirs,
|
||||
E2E_AFFECTED_MANIFEST,
|
||||
)
|
||||
expect(missing, `unmapped domains:\n${missing.join('\n')}`).toEqual([])
|
||||
})
|
||||
})
|
||||
86
tests/unit/e2eNavigationConventions.unit.spec.ts
Normal file
86
tests/unit/e2eNavigationConventions.unit.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Miss #54 (2026-07-30): a `page.goto` fired while the previous heavy RSC
|
||||
// navigation was still in flight aborted with `net::ERR_ABORTED`
|
||||
// (campaignSavedFilters, prod build). `goto` stays legal for cold loads and
|
||||
// URL contracts — the anti-pattern is two gotos with NOTHING between them
|
||||
// that settles the first navigation. A settle is any awaited interaction
|
||||
// with the landed page (assertion, fill, click, reload, login…): it proves
|
||||
// the previous route rendered before the next one starts.
|
||||
//
|
||||
// Miss #53 (2026-07-30): the biometrics probe is one-shot per island mount,
|
||||
// so a ceremony that starts before the CDP virtual authenticator answers
|
||||
// never sees the enrollment UI. Specs registering a virtual authenticator
|
||||
// must gate on `expectCampaignBiometricsReady` first.
|
||||
|
||||
const e2eRoot = resolve(process.cwd(), 'tests/e2e')
|
||||
|
||||
const specFiles = readdirSync(e2eRoot, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.spec.ts'))
|
||||
.map((entry) => resolve(entry.parentPath, entry.name))
|
||||
|
||||
const SETTLE_TOKENS = [
|
||||
'await expect(',
|
||||
'waitForURL(',
|
||||
'.click(',
|
||||
'.check(',
|
||||
'.fill(',
|
||||
'.press(',
|
||||
'.hover(',
|
||||
'clearCookies(',
|
||||
'.reload(',
|
||||
'campaign.login(',
|
||||
'.catch(',
|
||||
'await request.',
|
||||
'checkRadixWhenHydrated(',
|
||||
'expectCampaignBiometricsReady(',
|
||||
] as const
|
||||
|
||||
describe('e2e navigation discipline (miss #54)', () => {
|
||||
it('never fires two page.goto without settling the first navigation', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const file of specFiles) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n')
|
||||
let lastGoto: number | null = null
|
||||
let settledSince = false
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (SETTLE_TOKENS.some((token) => line.includes(token))) settledSince = true
|
||||
if (!line.includes('page.goto(')) continue
|
||||
if (lastGoto !== null && !settledSince) {
|
||||
offenders.push(
|
||||
`${relative(process.cwd(), file)}:${index + 1} (goto at ${lastGoto + 1} unsettled)`,
|
||||
)
|
||||
}
|
||||
lastGoto = index
|
||||
settledSince = false
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'settle the first navigation (await expect/waitForURL/click…) before the next page.goto, or navigate in-shell (miss #54)',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('e2e WebAuthn virtual authenticator (miss #53)', () => {
|
||||
it('gates every virtual-authenticator spec on expectCampaignBiometricsReady', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const file of specFiles) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
if (!source.includes('WebAuthn.addVirtualAuthenticator')) continue
|
||||
if (source.includes('expectCampaignBiometricsReady(')) continue
|
||||
offenders.push(relative(process.cwd(), file))
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'call expectCampaignBiometricsReady(page) before the surface whose island probes the authenticator',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
30
tests/unit/globalAccessConventions.unit.spec.ts
Normal file
30
tests/unit/globalAccessConventions.unit.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Pass 4 (2026-07-31): the four public globals spelled `update` as
|
||||
// `({ req: { user } }) => Boolean(user)` — "any authenticated user" — and
|
||||
// campaign JWTs authenticate against `/api/*`, so a campaign session (leader
|
||||
// included) could PATCH the public site's settings, home, metadata and privacy
|
||||
// policy. The lockdown is `payloadAdminOnly`; this guard keeps the
|
||||
// any-authenticated shape out of global update access for good. Collections
|
||||
// are covered elsewhere (every collection declares access; lockdown pins live
|
||||
// in `collectionAccessLockdown.int.spec.ts`).
|
||||
describe('global update access lockdown (Pass 4)', () => {
|
||||
const globalsRoot = resolve(process.cwd(), 'src/globals')
|
||||
|
||||
it('never spells global update access as "any authenticated user"', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const entry of readdirSync(globalsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
|
||||
const source = readFileSync(resolve(entry.parentPath, entry.name), 'utf8')
|
||||
if (/=>\s*Boolean\(user\)/.test(source)) offenders.push(entry.name)
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'global update access must name a policy (payloadAdminOnly, isPayloadAdmin…), not Boolean(user)',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
classifyTestScope,
|
||||
HIGH_RISK_EXACT,
|
||||
HIGH_RISK_PREFIXES,
|
||||
|
|
@ -19,7 +21,9 @@ describe('classifyTestScope (OPS5)', () => {
|
|||
'pnpm-lock.yaml',
|
||||
'src/collections/Post.ts',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'tests/helpers/campaignFixtures.ts',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
]) {
|
||||
expect(classifyTestScope([changed(path)]).mode, path).toBe('full')
|
||||
}
|
||||
|
|
@ -49,6 +53,32 @@ describe('classifyTestScope (OPS5)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('classifyStaticScope', () => {
|
||||
it('runs for src/tests and type/graph config diffs', () => {
|
||||
expect(classifyStaticScope([changed('src/lib/foo.ts')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('tsconfig.json')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('package.json')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('eslint.config.mjs')]).mode).toBe('code')
|
||||
})
|
||||
|
||||
it('skips for docs-only diffs', () => {
|
||||
expect(classifyStaticScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyBuildScope', () => {
|
||||
it('runs for build surface diffs', () => {
|
||||
expect(classifyBuildScope([changed('src/app/(frontend)/page.tsx')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('public/favicon.ico')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('next.config.mjs')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('package.json')]).mode).toBe('build')
|
||||
})
|
||||
|
||||
it('skips for docs-only diffs', () => {
|
||||
expect(classifyBuildScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectE2eSpecs (OPS5)', () => {
|
||||
const manifest = [
|
||||
{ prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] },
|
||||
|
|
|
|||
98
tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts
Normal file
98
tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
|
||||
// Miss #73 (2026-07-31): int specs pinning seeded municipality slugs
|
||||
// (`getMunicipality('cairu')` et al.) and mutating them deadlocked
|
||||
// `municipality_rels` under the parallel suite (Postgres 40P01), and e2e specs
|
||||
// deep-linking `?municipio=cairu` raced the same row from two workers. The
|
||||
// allocator (`getMunicipality()` / `claimMunicipality()`, Postgres sequence)
|
||||
// hands out a unique row per claim; pinning is what breaks the contract.
|
||||
//
|
||||
// Rule 3 is the literal-regex sibling of the anchored-template guard (P3-C):
|
||||
// `new RegExp(\`…\`)` was hardened there, but `/Cairu/i` literals dodged it —
|
||||
// and catalog names are not prefix-unique (Conde/Condeúba, Barra ×4, Laje ×3).
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const repoPath = (absolute: string) => relative(repoRoot, absolute)
|
||||
|
||||
const walkFiles = (root: string, extensions: readonly string[]): string[] =>
|
||||
readdirSync(root, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext)))
|
||||
.map((entry) => resolve(entry.parentPath, entry.name))
|
||||
|
||||
const specFiles = [
|
||||
...walkFiles(resolve(repoRoot, 'tests/int'), ['.ts']),
|
||||
...walkFiles(resolve(repoRoot, 'tests/e2e'), ['.ts']),
|
||||
]
|
||||
|
||||
/** `getMunicipality(…)` called with a non-empty argument (a pinned slug). */
|
||||
const FIXED_SLUG_CALL = /\bgetMunicipality\s*\(\s*[^)\s]/
|
||||
|
||||
const MUTATION_PATTERN = new RegExp(
|
||||
[
|
||||
'\\b(?:assignMunicipalityAdvisors(?:Record)?|touchMunicipality|createLeadership|createVotePledge|createCampaignDemand|createMunicipalityUpdate|setStateDeputyMunicipalitiesBatchRecord)\\s*\\(',
|
||||
'payload\\.update\\(\\s*\\{\\s*collection:\\s*[\'"]municipality[\'"]',
|
||||
].join('|'),
|
||||
)
|
||||
|
||||
describe('municipality allocator discipline (miss #73)', () => {
|
||||
it('never pins a fixed-slug municipality in a spec that mutates municipalities', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const file of specFiles) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
if (!FIXED_SLUG_CALL.test(source)) continue
|
||||
if (!MUTATION_PATTERN.test(source)) continue
|
||||
offenders.push(repoPath(file))
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'allocate with getMunicipality() (no slug) — read-only catalog lookups belong to getMunicipalityCatalogEntry',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('never deep-links a hardcoded municipality slug in e2e specs', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const file of walkFiles(resolve(repoRoot, 'tests/e2e'), ['.ts'])) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n')
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (/municipio=[a-z0-9-]/.test(line)) offenders.push(`${repoPath(file)}:${index + 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, 'interpolate claimMunicipality().slug into wizard deep links').toEqual([])
|
||||
})
|
||||
|
||||
it('anchors e2e name regexes that mention a catalog municipality', () => {
|
||||
const offenders: string[] = []
|
||||
const nameLiteral = /name:\s*\/([^/\n]+)\/i?\b/g
|
||||
const catalogNames = municipalityCatalog
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => name.length >= 4)
|
||||
|
||||
for (const file of walkFiles(resolve(repoRoot, 'tests/e2e'), ['.ts'])) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
nameLiteral.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = nameLiteral.exec(source)) !== null) {
|
||||
const pattern = match[1]!
|
||||
if (pattern.startsWith('^')) continue
|
||||
const hit = catalogNames.find((name) => pattern.toLowerCase().includes(name.toLowerCase()))
|
||||
if (hit) {
|
||||
const line = source.slice(0, match.index).split('\n').length
|
||||
offenders.push(`${repoPath(file)}:${line} (unanchored, matches "${hit}")`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'anchor with ^ (or locate by href) — catalog names are not prefix-unique',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue