Merge pull request #727 from fsolla/ci-remove-migration-lock

chore(ci): remove o migration-lock — deadlock de CI com PRs de schema paralelos
This commit is contained in:
Francisco Solla 2026-08-12 13:58:35 -03:00 committed by GitHub
commit a6d5a2695c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 29 additions and 47 deletions

View file

@ -61,7 +61,8 @@ Do not wait for human approval on normal deliveries. Auto-merge fires when **req
Branch protection on `main` requires only:
- `checks` (GitHub Actions rollup from `ci-pr.yml`)
- `migration-lock`
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
**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.

View file

@ -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). 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`).
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`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR e na main; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). 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

View file

@ -98,7 +98,7 @@ Issues nascidas de `plan-issue` com `--plan` **não** entram como `ready`: ficam
- **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.
- **Duplicata**: impossível em condições normais (alocador único + lock otimista + `agentId` idempotente); o tick cancela runs extras se alguém spawnar manualmente.
- **`needs:migration`/`serializes:[migrations]`**: o tick não spawna enquanto houver PR aberto tocando schema (`migration-lock`) — re-avalia a cada tick.
- **`needs:migration`/`serializes:[migrations]`**: o tick não spawna enquanto houver PR aberto tocando schema (predicado `countOpenSchemaPrs` em `agent-pool-github.mjs`) — re-avalia a cada tick.
- **Audit solitário**: `pause` antes, `resume` depois (a skill `engineering-audit` lembra).
## Smoke remoto (aceite — rodar na primeira ativação)

View file

@ -32,8 +32,7 @@ entrega.
2. **`pnpm push -u origin HEAD`**
3. PR **Ready** (nunca draft): `gh pr create --base main` com `Closes #<N>`
4. `gh pr merge --auto --merge` imediatamente
5. `gh pr checks <PR> --watch --required` (`checks` + `migration-lock`; ignore
Vercel Git)
5. `gh pr checks <PR> --watch --required` (`checks`; ignore Vercel Git)
6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
## Deltas por ator

View file

@ -2,8 +2,12 @@
# 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).
# phase-1 failure skips phase 2 via `needs` + explicit if.
#
# Schema migrations are NOT serialized across PRs (migration-lock removed
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
# PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's
# hard fail deadlocked every open schema PR the moment two coexisted.
#
# High-risk diffs (schema, lockfile, classifiers — see
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
@ -365,28 +369,6 @@ jobs:
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
migration-lock:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- name: Count open PRs touching schema files
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
count=$(gh pr list --state open --limit 100 --json number,files \
--jq '[.[] | select(.files != null and ([.files[].path | test("^(src/migrations/|src/payload-types.ts$|payload-types.ts$)")] | any))] | length')
echo "Open schema-touching PRs: $count"
if [ "$count" -gt 1 ]; then
echo "::error::More than one open PR touches src/migrations/ or payload-types.ts. Merge or close the other first."
exit 1
fi
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
checks:
name: checks
runs-on: ubuntu-latest
@ -401,7 +383,6 @@ jobs:
- int
- build
- e2e
- migration-lock
- plans-only-closes
if: always()
steps:

View file

@ -30,7 +30,7 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE
### 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.
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `checks` em `main` assim que o CI novo estiver verde.
## Comandos
@ -45,7 +45,7 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE
| `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 configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks`, `strict=false`, 0 reviews |
| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
@ -55,7 +55,7 @@ Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
## Contrato de PR
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
2. ≤1 PR aberto tocando migrations / `payload-types.ts` (`migration-lock`).
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
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

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-12):** **Remoção do `migration-lock`** (CI) — o check de `ci-pr.yml` que falhava quando >1 PR aberto tocava `src/migrations/`/`payload-types.ts` foi **removido** (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation.int.spec.ts`, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job `migration-lock` do `ci-pr.yml` + `needs` do rollup `checks` + contexto required da branch protection (`pnpm configure:branch-protection` agora aplica só `checks`); pool mantém a serialização de spawn de issues `needs:migration` (`countOpenSchemaPrs` em `agent-pool-github.mjs` — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))

View file

@ -3,7 +3,7 @@
Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted. Production deploys only from `ci.yml` after the full verifier is green.
- [x] Code PR opened (main-only cutover) — merged to `main`
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` + `migration-lock`, `strict=false`, 0 reviews)
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews)
- [x] No open PRs targeting `stage` at cutover time
- [x] Repo secrets present (`VERCEL_*`, `POOL_GITHUB_TOKEN`)
- [x] `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` synced from local `.vercel/project.json` (`vercel link`)

View file

@ -33,7 +33,6 @@ Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnp
| `src/lib` não importa `utilities/` (valores) | Pass 2 W2 | 2 | ESLint `no-restricted-imports` |
| 0 ciclos de import | Pass 2 | 4 | madge (`pnpm check:cycles`) |
| Dead code (arquivos/deps/exports em erro) | Pass 2 W4b | 4 | knip em CI |
| `migration-lock` (≤1 PR aberto tocando schema) | agentes paralelos | 4 | job em `ci-pr.yml` |
| PR só `docs/plans/` sem `Closes`/`Fixes`/`Resolves` | [#116](https://github.com/fsolla/teqo/issues/116) OPS12 | 4 | `scripts/lib/plansOnlyClosesGuard.mjs` + `scripts/check-plans-only-pr-closes.mjs` + job `plans-only-closes` em `ci-pr.yml` |
| Seed mínimo obrigatório com migration (contrato manifest) | OPS fluxo PR | 3 + 5 | `tests/unit/seedMinimalManifest.unit.spec.ts` + job CI migrate→seed→int |
| Regex de template ancorada em e2e | P3-C | 3 | `codebaseConventions.unit.spec.ts` (ampliada para literais no Pass 4) |

View file

@ -4,8 +4,9 @@
* pnpm configure:branch-protection # apply
* pnpm configure:branch-protection -- --dry-run
*
* main: required checks `checks` + `migration-lock`, strict=false, 0 reviews.
* Feature PRs auto-merge when those contexts are green.
* main: required checks `checks`, strict=false, 0 reviews.
* Feature PRs auto-merge when that context is green.
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
*/
import { execFileSync } from 'node:child_process'
@ -40,7 +41,7 @@ console.log(`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' :
ghApiJson('PUT', `repos/${repo}/branches/main/protection`, {
required_status_checks: {
strict: false,
contexts: ['checks', 'migration-lock'],
contexts: ['checks'],
},
enforce_admins: false,
required_pull_request_reviews: {
@ -51,6 +52,4 @@ ghApiJson('PUT', `repos/${repo}/branches/main/protection`, {
allow_deletions: false,
})
console.log(
'[configure:branch-protection] done (main: checks + migration-lock, strict=false, 0 reviews).',
)
console.log('[configure:branch-protection] done (main: checks, strict=false, 0 reviews).')

View file

@ -2,7 +2,7 @@
/**
* 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.
* (`origin/main` by default).
*
* Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape
* during pipeline cutover: `git push --no-verify` (documented in AGENT-OPS).

View file

@ -5,9 +5,10 @@
* The pool consumes the SAME queue as `agent:claim` (buildClaimQueue) and then
* applies stricter, fail-closed predicates: anything that needs a human
* (`requirements-changed`, `needs:consent`, `blocked`) is out, migration-touching
* issues yield to the migration-lock while a schema PR is open, and an issue
* the pool already failed twice stays out (circuit breaker — enforced per
* candidate at claim time, where the comments are already being read).
* issues wait while a schema PR is open (pool-level serialization — the CI
* migration-lock was removed 2026-08-12), and an issue the pool already failed
* twice stays out (circuit breaker — enforced per candidate at claim time,
* where the comments are already being read).
*/
import { labelNames } from './agent-github.mjs'

View file

@ -72,8 +72,8 @@ export const prClosingIssue = (prs, issueNumber) =>
prs.find((pr) => new RegExp(`(?:closes|fixes) #${issueNumber}\\b`, 'i').test(pr.body ?? '')) ??
null
// Same path predicate as the ci-pr.yml migration-lock job (single source of
// truth for "schema-touching PR").
// Path predicate for "schema-touching PR" — the pool serializes migration
// spawns while one is open (the CI migration-lock was removed 2026-08-12).
const SCHEMA_PR_PATH = /^(src\/migrations\/|src\/payload-types\.ts$|payload-types\.ts$)/
export const countOpenSchemaPrs = () =>