ops(OPS61): flip de issues pós-merge com PAT + gate do rollup checks + branch protection real no Forgejo
Some checks failed
CI (PR) / scope (pull_request) Successful in 33s
CI (PR) / lint (pull_request) Successful in 43s
CI (PR) / format (pull_request) Successful in 54s
CI (PR) / docs-guards (pull_request) Successful in 8s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
CI (PR) / typecheck (pull_request) Successful in 34s
CI (PR) / knip (pull_request) Successful in 33s
CI (PR) / cycles (pull_request) Successful in 32s
CI (PR) / unit (pull_request) Successful in 58s
CI (PR) / int (pull_request) Successful in 2m5s
CI (PR) / build (pull_request) Has been skipped
CI (PR) / e2e (1) (pull_request) Has been skipped
CI (PR) / e2e (2) (pull_request) Has been skipped
CI (PR) / checks (pull_request) Successful in 2s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 31m47s

- issue-done/plan-issue-ready: env FORGEJO_API_TOKEN (GITHUB_TOKEN 403 em
  Issues) + fork guard (checkout de fork roda script do fork com o PAT — RCE)
- forgejo-issue-transition: falha de flip vira exit 1 (job vermelho, nunca
  skip silencioso)
- waitForChecks: exige o rollup CI (PR) / checks presente+verde (raça PR #52:
  snapshot com jobs não-agendados parecia verde); mergeable=false só conta
  como conflito após a cascata assentar; timeouts 30→45min (CLI) e 40→55min
  (workflow)
- configure-branch-protection: contexto real CI (PR) / checks* (Forgejo casa
  por glob contra 'CI (PR) / checks (pull_request)'; literal 'checks' nunca
  casaria), idempotente GET→POST/PATCH, verificação pós-aplicação; decisão
  pura em scripts/lib/branch-protection.mjs
- docs honestas (AGENT-OPS, agent-pr-workflow.mdc, CUTOVER-MAIN-ONLY, README)
- testes: raça do rollup/rollup failure/mergeable pré-settle +
  branchProtection create/update/noop + glob real
This commit is contained in:
Francisco Solla 2026-08-18 16:39:05 -03:00
parent 8cc9e078ae
commit f077546599
17 changed files with 507 additions and 60 deletions

View file

@ -56,11 +56,13 @@ gh pr merge --auto --rebase
Do not wait for human approval on normal deliveries. Auto-merge fires when **required** checks pass.
## Required checks vs Vercel
## Required checks
Branch protection on `main` requires only:
Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
- `checks` (GitHub Actions rollup from `ci-pr.yml`)
- `CI (PR) / checks` — the ci-pr cascade rollup (the only context that covers
every ci-pr job; on the server the rule matches it as a glob,
`CI (PR) / checks*`)
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
@ -83,7 +85,11 @@ There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` ve
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
equivalente Forgejo do `gh pr merge --auto --rebase`). Cobre o caso em que o
equivalente Forgejo do `gh pr merge --auto --rebase`). O wait exige o rollup
`CI (PR) / checks` POSTADO e verde (OPS61) — statuses de jobs ainda não
agendados não podem parecer verde — e a regra de branch protection no servidor
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito.
Cobre o caso em que o
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é
tocado** — o draft é o veto do ator (skip do CLI). Não há opt-out por skill

View file

@ -31,7 +31,10 @@ jobs:
github.event.pull_request.state == 'open' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 40
# OPS61: the CLI now waits for the full CI (PR) cascade (the `checks`
# rollup posts only after every ci-pr job, incl. e2e shards) — 55 min keeps
# 10 min of slack over the CLI's 45 min timeout.
timeout-minutes: 55
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:

View file

@ -1,6 +1,11 @@
# Main merge flips claimed issues to done + in-prod.
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
# (plain Node, reads the PR body from the API itself).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes, and the flip was silently skipped (script logged the 403
# and the job still ended success). The fork guard is the same class as
# agent-pr-ready-automerge.yml: on a merged fork PR the checkout pulls the
# fork's own script, which would run with the repo PAT (RCE).
name: Main merge flips claimed issues to done + in-prod
on:
@ -9,8 +14,10 @@ on:
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:

View file

@ -5,6 +5,11 @@
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
# issue label writes (same silent-skip failure as issue-done-on-main-merge).
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
# which would run with the repo PAT (RCE — same class as
# agent-pr-ready-automerge.yml).
name: Promote plan-issue Related on main merge
on:
@ -16,6 +21,7 @@ jobs:
plan-issue-ready:
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository &&
(
contains(github.event.pull_request.body, 'Related #') ||
contains(github.event.pull_request.body, 'related #') ||
@ -23,6 +29,8 @@ jobs:
)
runs-on: ubuntu-latest
timeout-minutes: 5
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable

View file

@ -10,7 +10,7 @@
> 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`.
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, `POOL_GITHUB_TOKEN`; `pnpm configure:branch-protection` (main: required `CI (PR) / checks`).
>
> 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`.

View file

@ -30,23 +30,23 @@ main → ci.yml full suite verde → job `deploy` (homeserver, OPS53 — merge e
### 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` 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 `CI (PR) / checks` 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 |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `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`, `strict=false`, 0 reviews |
| `pnpm db:seed:minimal` | DB mínimo sintético |
| Comando | Faz |
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `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`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
@ -73,8 +73,8 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` |
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks`; job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) |
| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera checks e mergea por rebase; draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
@ -84,11 +84,13 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
### Secrets (humano, uma vez)
| Secret | Uso |
| ------------------- | ------------------------------------------------------------------------------------ |
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) |
| `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) |
| Secret | Uso |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`); também nos workflows pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo 403 em Issues (OPS61) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) |
| `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) |
Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear — a regra no servidor é a defesa final (o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde). Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53): o job `deploy` do `ci.yml` roda no host do runner (workstation) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy. Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.

View file

@ -36,6 +36,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-18):** **OPS59** — o pre-push local não roda mais e2e: ele roda **uma vez, no CI** (PR e main — gateia merge e deploy). O espelho `scripts/gate-ci.mjs` cortou o bloco e2e da cascata (playwright install, build `.next-e2e`, `test:e2e`) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém `e2e=<mode>` e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check `unmapped` duplicado removido (`ci-scope.mjs` já o imprime no stderr). `package.json`, `.husky/pre-push`, `git-push.mjs` e os workflows `.forgejo/*` intocados (CI verificado, não alterado: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`). `tests/unit/ciSkipInvariants.unit.spec.ts` tirou `gate-ci.mjs` da lista de arquivos que devem conter `.next-e2e` (o gate local não builda mais e2e; `run-e2e-affected.mjs`/`playwright.config.ts` seguem cobertos — e2e local opcional intacto). Docs sincronizadas: `AGENTS.md` (checklist local item 3 — `test:e2e`/`test:all` saem da obrigatória; "e2e = CI, local opcional `pnpm test:e2e:affected`"), `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: `AGENT-OPS.md` e skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via `--no-verify` documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: `pnpm gate:ci` local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em `teqo_test`; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (`gate-ci.mjs`).
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b, raça do waitForChecks — evidência PR #52:** mergeou com o CI vermelho porque um snapshot com statuses verdes mas jobs ainda não-agendados (sem status) parecia completo; o `docs-guards` postou failure depois): `waitForChecks` passa a exigir o rollup `CI (PR) / checks` **presente e verde** (o único contexto que só existe quando a cascata inteira do ci-pr assentou), `mergeable === false` só conta como conflito após a cascata assentar, timeouts sobem (CLI 30→45 min, workflow 40→55 min — o wait agora cobre e2e/int/build de verdade). A regra no servidor é a defesa final (o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — confirmado na fonte do Forgejo). **Validação ao vivo na própria entrega:** a PR desta entrega (`Closes #53`) mergeou pelo novo gate do rollup com a regra recém-aplicada no servidor, e o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (raça do rollup, rollup failure, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- <sha>"`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-<sha>`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte.

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` until 2026-08-12), `strict=false`, 0 reviews)
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews) — **GitHub-era; reaplicado no Forgejo em 2026-08-18 (OPS61) com o contexto real `CI (PR) / checks` (glob `CI (PR) / checks*`)**
- [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

@ -0,0 +1 @@
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b, raça do waitForChecks — evidência PR #52:** mergeou com o CI vermelho porque um snapshot com statuses verdes mas jobs ainda não-agendados (sem status) parecia completo; o `docs-guards` postou failure depois): `waitForChecks` passa a exigir o rollup `CI (PR) / checks` **presente e verde** (o único contexto que só existe quando a cascata inteira do ci-pr assentou), `mergeable === false` só conta como conflito após a cascata assentar, timeouts sobem (CLI 30→45 min, workflow 40→55 min — o wait agora cobre e2e/int/build de verdade). A regra no servidor é a defesa final (o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — confirmado na fonte do Forgejo). **Validação ao vivo na própria entrega:** a PR desta entrega (`Closes #53`) mergeou pelo novo gate do rollup com a regra recém-aplicada no servidor, e o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (raça do rollup, rollup failure, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).

View file

@ -0,0 +1,94 @@
# Impl: OPS61 — Forgejo: contratos de merge/flip quebrados — labels não flipam (GITHUB_TOKEN 403) e branch protection de main não reportada
Status: aprovado
Atualizado em: 2026-08-18
Issue: #53
Intenção: docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection.md
Appetite restante: herdado (~1 dia de ops)
## Leitura da intenção
- **Outcome:** (S1) os dois workflows pós-merge usam PAT com `issues:write` — `Closes/Fixes #N` flipe `done`+`in-prod` e `Related #N` promova `ready` em PRs reais; (S3) a regra de branch protection de `main` (required status check = rollup `checks` do ci-pr) existe no servidor ou as docs dizem a verdade ("gate no script, não no servidor"); (2b) `waitForChecks` espera o rollup `CI (PR) / checks` — snapshot com jobs não-agendados nunca parece verde.
- **O que NÃO negociar:** o flip deve **falhar ruidoso** se falhar (rejeitado: engolir 403); nenhuma edição de Issues `in-progress` de terceiros; nada de prod DB (ops de Forgejo, não de DB).
- **O que reavaliar:** qual PAT usar (intenção sugere `POOL_GITHUB_TOKEN`); por que a regra não está no servidor; o contexto exato que a regra exige (a intenção supunha `checks`).
## Achados da exploração (evidência ao vivo 2026-08-18)
1. **S1 root cause:** `forgejo-api.mjs` resolve token na ordem `FORGEJO_API_TOKEN` → `GITHUB_TOKEN`. Nos dois workflows pós-merge nenhum `env:` injeta o PAT → cai no `GITHUB_TOKEN` nativo, que 403 em labels/comments de Issues. Os jobs terminam success porque os scripts engolem o erro no `try/catch`.
2. **Fork guard ausente** nos dois workflows pós-merge: `on: pull_request: types: [closed]` dispara também para PRs de fork e o `actions/checkout` faz checkout do **head do fork** → o script do fork roda com o token do Actions. O workflow irmão `agent-pr-ready-automerge.yml` tem o guard `head.repo.full_name == github.repository` exatamente por isso (RCE com o token). Injetar um PAT com `issues:write` nesses workflows **sem** o guard agrava o buraco — o guard entra junto.
3. **S3 root cause:** `GET /repos/fsolla/teqo/branch_protections` → `[]` (0 regras, confirmado). O changelog do OPS50 lista `pnpm configure:branch-protection` como **passo humano pendente** ("Pendente (parte 2): … passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, …)") — o script nunca rodou pós-port. `CUTOVER-MAIN-ONLY.md`, `AGENT-OPS.md` e `agent-pr-workflow.mdc` documentam a regra como aplicada — docs mentem.
4. **Contexto real do rollup:** statuses observados do sha do PR #52/#48/#50: `CI (PR) / checks (pull_request)` (contexto inclui o sufixo ` (evento)`). O `checks` do `ci-pr.yml` tem `if: always()` + `needs` de todos os jobs → só posta depois da cascata inteira.
5. **Como o Forgejo casa os contextos da regra:** fonte (branch `forgejo`): `MergeRequiredContextsCommitStatus` compila cada `status_check_contexts` com **glob** (gobwas/glob, `glob.Compile(ctx)`) e casa contra `commitStatus.Context`; contexto exigido que não casa **nenhum** status → `pending` (bloqueia merge). Ou seja: `checks` literal **nunca** casaria `CI (PR) / checks (pull_request)` — a regra, mesmo se aplicada com o valor atual do script, bloquearia todo merge silenciosamente. O glob `CI (PR) / checks*` casa o rollup real (o sufixo ` (pull_request)` não contém `/`, então casa com ou sem semântica de separador).
6. **O servidor realmente bloqueia:** o merge POST passa por `CheckPullBranchProtections` → `IsPullCommitStatusPass` → `ErrDisallowedToMerge` (405 "PR is not ready to be merged"). A regra no servidor é a defesa final real (o plano da intenção suspeitava que o Forgejo 9 pudesse não expor; a expõe e bloqueia).
7. **Timeouts:** `waitForChecks` default 30 min; o workflow `ready-automerge` tem `timeout-minutes: 40`. Com o gate do rollup esperamos a cascata inteira (incl. e2e×2, int, build) — 30 min fica apertado sob carga (capacity 4, pool em paralelo). Subir CLI para 45 min e workflow para 55 min.
## Abordagem recomendada
```mermaid
flowchart LR
A[PRs pós-merge: env FORGEJO_API_TOKEN + fork guard] --> B[flip real com PAT]
C[waitForChecks: gate do rollup CI (PR) / checks*] --> D[merge só com cascata completa]
E[configure-branch-protection: glob real + idempotente] --> F[regra no servidor = defesa final]
G[PR da entrega Closes #53] --> H[verificação ao vivo de S1 e S3]
```
**Opções consideradas:**
- S1: (A) `FORGEJO_API_TOKEN` nos dois workflows; (B) `POOL_GITHUB_TOKEN`; (C) PAT dedicado novo.
- S3: (A) script corrigido (glob real) + idempotente + rodar; (B) rodar script como está (`checks` literal); (C) contexto literal exato `CI (PR) / checks (pull_request)`; (D) docs-only ("gate no script").
- 2b: (A) gate do rollup no `waitForChecks` + `mergeable === false` só após cascata assentar + timeouts maiores; (B) lista exaustiva de contextos; (C) só regra no servidor.
**Recomendação:** S1-A + S3-A + 2b-A — porque:
- **S1-A:** o `forgejo-api.mjs` já prefere `FORGEJO_API_TOKEN`; é o PAT que o pool usa em produção para o mesmo tipo de escrita (claim = flip de labels, comments, issues) — escopo `issues:write` comprovado; secret já existe no Forgejo (pool roda verde). `POOL_GITHUB_TOKEN` (B) existe para `/actions/variables` (o 403 que ele resolve é de variables, não de issues) e é redundante para este uso; (C) cria provisioning humano sem ganho.
- **S3-A:** o glob `CI (PR) / checks*` é o único valor que casa os statuses reais (achado 5); o script vira GET→POST/PATCH idempotente com dry-run honesto (criar/atualizar/no-op), fechando a promessa "idempotente" do próprio doc. (B) criaria regra que nunca casa → todo merge bloqueado (pior que nada). (C) funciona (casamento exato) mas é frágil ao formato do sufixo ` (evento)`; `*` é resiliente e igualmente preciso (o literal `CI (PR) / checks` antes do `*` não casa o `CI / checks` do push).
- **2b-A:** o rollup é o único contexto com `if: always()` sobre a cascata toda; exigir presença+success dele mata a raça (snapshot pré-`docs-guards` não tem rollup → pending). `mergeable === false` passa a lançar só depois do rollup assentar (senão a regra recém-aplicada faria o CLI lançar "conflito" prematuro durante a CI). (B) é frágil a mudanças de jobs; (C) sozinho faz o merge falhar ruidoso em vez de esperar — o gate no script é que espera.
- **Fail-loud do flip (racional da intenção):** `forgejo-issue-transition.mjs` passa a sair com exit 1 se qualquer flip falhar (o job é o propósito inteiro do workflow); `agent-promote-related-on-merge.mjs` mantém o soft-skip documentado (safety net multi-issue por design — falha de uma Issue não aborta irmãs; a troca de token é o fix da causa raiz).
**Rejeitadas:** engolir 403 no script (intenção veto); `POOL_GITHUB_TOKEN` (semântica de variables); `checks` literal (glob nunca casa — merges bloqueados para sempre); lista de contextos (frágil); confiar só no servidor (merge falha em vez de esperar); docs-only (a regra funciona e é a defesa final real).
### Componentes / mudanças
- **`.forgejo/workflows/issue-done-on-main-merge.yml`**: `if:` ganha o fork guard (`head.repo.full_name == github.repository`); job `env: FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}`; comentário do header explica o PAT (GITHUB_TOKEN 403 em Issues).
- **`.forgejo/workflows/plan-issue-ready-on-main-merge.yml`**: idem (fork guard + env + header).
- **`scripts/lib/forgejo-api.mjs`** (`waitForChecks`): novo gate — entre os statuses do sha, o contexto que começa com `CI (PR) / checks` deve existir e ser `success` (failure/error → throw; ausente/pending → continua poll); `mergeable === false` só lança se o rollup já assentou (success); restante (failure/error de outros contextos, pending, draft, timeout) preservado.
- **`scripts/forgejo-pr-automerge.mjs`**: passa `timeoutMs: 45 * 60 * 1000` explícito ao `autoMerge`.
- **`.forgejo/workflows/agent-pr-ready-automerge.yml`**: `timeout-minutes: 40` → `55`.
- **`scripts/forgejo-issue-transition.mjs`**: falhas de flip viram exit 1 (log continua por Issue, com `continue` entre irmãs — não aborta a lista inteira, mas o job vermelha).
- **`scripts/configure-branch-protection.mjs`**: `status_check_contexts: ['CI (PR) / checks*']`; fluxo idempotente GET → POST (cria) / PATCH (atualiza se drift) / no-op (igual); dry-run imprime a ação exata; após aplicar, re-lê e loga a regra.
- **`scripts/lib/branch-protection.mjs`** (novo, puro): `planBranchProtectionRule(existing, desired)` → `create | update | noop` (testável). Reusa o shape da API.
- **Docs:** `docs/AGENT-OPS.md` (tabela de workflows: token usado; secrets: nota que `FORGEJO_API_TOKEN` cobre os pós-merge; estado da branch protection), `.agents/rules/agent-pr-workflow.mdc` (seção "Required checks": contexto literal `CI (PR) / checks`, regra aplicada no servidor; seção safety net: gate do rollup), `docs/CUTOVER-MAIN-ONLY.md` (checkbox da branch protection = aplicado em 2026-08-18), `README.md` (linha 5 stale: remove `VERCEL_*`, atualiza a linha da branch protection).
- **Tests:** `tests/unit/forgejoApi.unit.spec.ts` — novos casos de `waitForChecks`: (a) statuses verdes mas sem rollup → continua poll (a raça); (b) rollup presente success → retorna; (c) rollup failure → lança; (d) `mergeable === false` antes do rollup assentar → continua; (e) `mergeable === false` após rollup success → lança. `tests/unit/branchProtection.unit.spec.ts` (novo) — decisão create/update/noop + glob casa os contextos observados.
- **Migration:** sem migration (não toca schema/DB).
- **Access / Consent:** N/A.
- **UI:** N/A (A).
## Fases verificáveis
1. **Scripts + workflows + docs + testes** — diff completo do PR da entrega; `pnpm gate:fast` local (lint/format/typecheck/knip/cycles/unit).
2. **Push + PR** — `pnpm push`; PR `Closes #53`. Enquanto a CI roda: **aplicar a regra no servidor** (`pnpm configure:branch-protection -- --dry-run` → aplicar → `GET /branch_protections` confirma). A auto-merge da própria PR prova o gate do rollup (2b) e a regra (S3) num único ciclo: `waitForChecks` espera a cascata → rollup green → merge permitido pela regra → merge. Se o glob estiver errado, o merge 405/`mergeable=false` falha ruidoso e corrigimos antes de qualquer outra PR.
3. **Flip (S1) ao vivo** — o merge dispara `issue-done-on-main-merge.yml` com o PAT; a Issue #53 vira `done`+`in-prod` com comentário. Se não flipar, o job agora vermelha (exit 1) — visível.
4. **Changelog** — `docs/changelog/2026-08-18-ops61.md` + `pnpm changelog:build`.
## Rabbit holes / Não escopo (engenharia)
- Não mudar o soft-skip do `agent-promote-related-on-merge.mjs` (design documentado; token é o fix).
- Não mexer no merge do pool (usa os mesmos caminhos — herda o fix).
- Não reaplicar/alterar `enable_push`/approvals da regra (0 reviews, strict=false — contrato atual).
- Não corrigir o `README.md` inteiro (drift pré-existente pós-OPS50) — só a linha do escopo.
- Guard de fork como débito? Não — entra na entrega (achado 2, mesmo arquivo, mesma classe do guard do irmão).
## Riscos e mitigação
- **Glob da regra errado → todo merge bloqueado:** mitigação — verificação empírica na própria PR da entrega (Fase 2); falha é ruidosa (405/throw) e reversível (PATCH contexts); a fonte do Forgejo confirmou o casamento por glob e o valor observado.
- **`mergeable === false` prematuro com regra aplicada durante a CI:** mitigação — lançar só após o rollup assentar; a PR da entrega exercita exatamente esse caminho.
- **Cascata completa estoura o timeout (esperamos e2e agora):** mitigação — CLI 45 min + workflow 55 min; medir na PR da entrega e ajustar se a folga for pequena.
- **PRs de fork param de flipar issues pós-merge:** comportamento novo e intencional (guard) — documentado no changelog; flip manual para fork PRs se algum dia precisar.
- **Secret `FORGEJO_API_TOKEN` não ter `issues:write`:** mitigação — o pool já escreve issues/labels/comments com ele em produção (evidência: runs verdes); se faltar escopo, o job vermelha (exit 1) e o humano ajusta o PAT — visível, não silencioso.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (flip real, regra no servidor, rollup gate, fail ruidoso)
- [ ] Invariantes AGENTS/engineering-standards (zero-dep scripts, forks nunca rodam código com token do repo, docs honestas)
- [ ] Testes de domínio previstos (unit: waitForChecks ×5, branchProtection ×3) onde os write/merge paths mudam
- [ ] Verificação ao vivo: #53 flipe `done`+`in-prod` com PAT; `GET /branch_protections` mostra a regra; PR da entrega mergeia pelo novo gate

View file

@ -1,15 +1,22 @@
/**
* One-time / drift repair for branch protection (main-only) — Forgejo.
* Drift repair for branch protection (main-only) — Forgejo. Idempotent: reads
* the existing rules and creates / updates / no-ops (never duplicates).
*
* pnpm configure:branch-protection # apply
* pnpm configure:branch-protection -- --dry-run
*
* main: required status checks `checks`, strict=false, 0 reviews.
* Feature PRs auto-merge when that context is green.
* main: required status check `CI (PR) / checks*` (the ci-pr cascade rollup —
* OPS61: Forgejo matches the contexts as globs against the real status
* contexts like `CI (PR) / checks (pull_request)`, so the old literal `checks`
* would match nothing), strict=false, 0 reviews. Feature PRs auto-merge when
* that context is green; the server rule is the final defense against merges
* before the cascade settles (the waitForChecks rollup gate is the client
* half).
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
*/
import { parseArgs } from './lib/agent-forgejo.mjs'
import { DESIRED_RULE, planBranchProtectionRule } from './lib/branch-protection.mjs'
import { createApi } from './lib/forgejo-api.mjs'
const { flags } = parseArgs(process.argv.slice(2), new Set())
@ -17,22 +24,52 @@ const dryRun = flags['dry-run'] === true
const api = createApi({})
const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
const ruleName = DESIRED_RULE.rule_name
const payload = {
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['checks'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
}
const existingRules = await api.listBranchProtections()
const existing =
existingRules.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
const { action } = planBranchProtectionRule(existing)
console.log(`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''}`)
console.log(
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${ruleName}": ${
action === 'noop'
? 'já conforme — nada a fazer'
: action === 'update'
? 'atualizar (drift)'
: 'criar'
}`,
)
if (dryRun) {
console.log(`[dry-run] POST /repos/${repo}/branch_protections ${JSON.stringify(payload)}`)
if (action === 'noop') {
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(DESIRED_RULE)}`)
process.exit(0)
}
await api.updateBranchProtection('main', payload)
console.log('[configure:branch-protection] done (main: checks, strict=false, 0 reviews).')
const payload = { ...DESIRED_RULE, branch_name: ruleName }
const target = action === 'create' ? '/branch_protections' : `/branch_protections/${ruleName}`
const method = action === 'create' ? 'POST' : 'PATCH'
if (dryRun) {
console.log(`[dry-run] ${method} /repos/${repo}${target} ${JSON.stringify(payload)}`)
process.exit(0)
}
if (action === 'create') {
await api.updateBranchProtection(ruleName, payload)
} else {
await api.editBranchProtection(ruleName, payload)
}
const after = await api.listBranchProtections()
const applied =
after.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
if (!applied) {
console.error(
`[configure:branch-protection] FALHOU: ${method} ${target} respondeu mas a regra "${ruleName}" não aparece no servidor.`,
)
process.exit(1)
}
console.log(
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(applied)}`,
)

View file

@ -5,6 +5,11 @@
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
* on), so the workflow only needs the PR number.
*
* OPS61: a failed flip exits 1 — the flip is the whole purpose of this
* workflow, and the 403 era proved that a swallowed error ends the job
* "success" while the status labels lie. Sibling issues are still attempted
* (the loop does not abort), but the job goes red so the breakage is visible.
*
* node scripts/forgejo-issue-transition.mjs --pr <N>
*/
@ -51,6 +56,7 @@ if (numbers.length === 0) {
process.exit(0)
}
let flipFailed = false
for (const number of numbers) {
try {
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
@ -60,8 +66,15 @@ for (const number of numbers) {
)
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
} catch (error) {
console.log(
`[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`,
flipFailed = true
console.error(
`[forgejo-issue-transition] #${number}: FLIP FALHOU — ${error instanceof Error ? error.message : error}`,
)
}
}
if (flipFailed) {
console.error(
`[forgejo-issue-transition] PR #${prNumber}: ${numbers.length} Issue(s) citada(s), pelo menos 1 flip falhou — os labels de status podem estar mentindo.`,
)
process.exit(1)
}

View file

@ -67,6 +67,10 @@ try {
merged,
pr: after,
} = await api.autoMerge(prNumber, {
// OPS61: the wait now covers the whole CI (PR) cascade (the `checks`
// rollup only posts after every ci-pr job), so the 30 min default was too
// tight under load — e2e shards + int + build run before the rollup.
timeoutMs: 45 * 60 * 1000,
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
})
if (merged) {

View file

@ -0,0 +1,60 @@
/**
* Pure decision for the main branch protection rule (OPS61).
*
* The desired rule is the repo contract: main requires the CI (PR) cascade
* rollup as the only status check (`strict=false`, 0 reviews). The context is
* a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob
* against the real commit-status contexts (`CI (PR) / checks (pull_request)` —
* the ` (event)` suffix is part of the context), so the literal `checks` would
* match nothing and block every merge.
*/
export const DESIRED_RULE = Object.freeze({
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['CI (PR) / checks*'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
})
const COMPARED_FIELDS = [
'rule_name',
'enable_status_check',
'status_check_contexts',
'enable_push',
'required_approvals',
'dismiss_stale_approvals',
]
const sameStrings = (have, want) =>
have.length === want.length &&
want.every((value) => have.includes(value)) &&
have.every((value) => want.includes(value))
/**
* True when the existing rule (from the API, already normalized) matches the
* desired rule on every compared field — array fields as sets.
*/
export const ruleMatches = (existing, desired = DESIRED_RULE) => {
if (!existing) return false
return COMPARED_FIELDS.every((field) => {
const want = desired[field]
const have = existing[field]
if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want)
return have === want
})
}
/**
* What to do with the rule: `create` (no rule), `update` (rule exists with
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
*
* @param {object|null} existing - normalized rule from the API, or null
* @param {object} [desired]
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => {
if (ruleMatches(existing, desired)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}

View file

@ -269,21 +269,51 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => {
body: { ...payload, branch_name: branch },
}),
/** GET /api/v1/repos/{owner}/{repo}/branch_protections — normalized rules. */
listBranchProtections: async () => {
const rules = await request(`/repos/${owner}/${name}/branch_protections`)
return (Array.isArray(rules) ? rules : []).map((rule) => ({
rule_name: rule.rule_name ?? '',
branch_name: rule.branch_name ?? '',
enable_status_check: Boolean(rule.enable_status_check),
status_check_contexts: rule.status_check_contexts ?? [],
enable_push: Boolean(rule.enable_push),
required_approvals: rule.required_approvals ?? 0,
dismiss_stale_approvals: Boolean(rule.dismiss_stale_approvals),
}))
},
/** PATCH /api/v1/repos/{owner}/{repo}/branch_protections/{name} — updates an existing rule. */
editBranchProtection: (name, payload) =>
request(`/repos/${owner}/${name}/branch_protections/${name}`, {
method: 'PATCH',
body: payload,
}),
/**
* All required statuses on the PR head are success (or a failure exists),
* and Forgejo finished computing mergeability. Returns the last PR read.
* A draft PR stops the wait (converted mid-run): the caller decides what
* "no merge" means — the CLI skips non-`cursor/*` drafts (OPS57 veto).
*
* OPS61 rollup gate: the CI (PR) cascade's aggregate job (`checks`, the
* `if: always()` rollup over every ci-pr job) must have POSTED and be
* success. Job statuses are posted as jobs finish, so a snapshot with zero
* pending can still miss jobs that were not scheduled yet — that race
* merged PR #52 with the CI red. The rollup only exists once the whole
* cascade settled (it `needs` every job), so its absence keeps the wait
* going. `mergeable === false` is only trusted after the cascade settled —
* while jobs still run (or the branch-protection rule blocks), Forgejo may
* report false without meaning "conflict".
*/
waitForChecks: async (number, { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log } = {}) => {
const started = Date.now()
for (;;) {
const pr = await api.getPullRequest(number)
if (!pr || pr.state !== 'OPEN' || pr.merged || pr.isDraft) return pr
if (pr.mergeable === false) {
throw new Error(`PR #${number} não mergeável (conflito?) — resolver manualmente.`)
}
const statuses = await api.listCommitStatuses(pr.head.sha)
const rollup = statuses.find((entry) => entry.context.startsWith('CI (PR) / checks'))
const cascadeSettled = Boolean(rollup && rollup.state === 'success')
const failed = statuses.filter(
(entry) => entry.state === 'failure' || entry.state === 'error',
)
@ -292,15 +322,18 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => {
const names = failed.map((entry) => entry.context).join(', ')
throw new Error(`Checks falharam no PR #${number}: ${names}`)
}
if (statuses.length > 0 && pending.length === 0 && pr.mergeable === true) return pr
if (pr.mergeable === false && cascadeSettled) {
throw new Error(`PR #${number} não mergeável (conflito?) — resolver manualmente.`)
}
if (cascadeSettled && pending.length === 0 && pr.mergeable === true) return pr
if (Date.now() - started > timeoutMs) {
throw new Error(`Timeout esperando checks do PR #${number}.`)
}
if (log)
log(
`[pr#${number}] ${statuses.length} status, ${pending.length} pendente, mergeable=${
pr.mergeable ?? 'computando'
} — aguardando…`,
`[pr#${number}] ${statuses.length} status, ${pending.length} pendente, rollup=${
rollup ? rollup.state : 'ausente'
}, mergeable=${pr.mergeable ?? 'computando'} — aguardando…`,
)
await new Promise((resolve) => setTimeout(resolve, pollMs))
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
DESIRED_RULE,
planBranchProtectionRule,
ruleMatches,
} from '../../scripts/lib/branch-protection.mjs'
describe('branch-protection rule planning (OPS61)', () => {
it('plans create when no rule exists', () => {
expect(planBranchProtectionRule(null).action).toBe('create')
})
it('plans noop when the rule already matches', () => {
const compliant = { ...DESIRED_RULE, branch_name: 'main' }
expect(planBranchProtectionRule(compliant).action).toBe('noop')
expect(ruleMatches(compliant)).toBe(true)
})
it('plans update when the rule drifts', () => {
const drift = { ...DESIRED_RULE, status_check_contexts: ['checks'] }
expect(planBranchProtectionRule(drift).action).toBe('update')
expect(planBranchProtectionRule(drift).action).not.toBe('noop')
})
it('detects drift in the contexts list (extra context)', () => {
const extra = {
...DESIRED_RULE,
status_check_contexts: ['CI (PR) / checks*', 'migration-lock'],
}
expect(planBranchProtectionRule(extra).action).toBe('update')
})
it('compares status_check_contexts as a set (order does not matter)', () => {
const desired = { ...DESIRED_RULE, status_check_contexts: ['a', 'b'] }
const existing = { ...desired, status_check_contexts: ['b', 'a'] }
expect(ruleMatches(existing, desired)).toBe(true)
expect(ruleMatches({ ...desired, status_check_contexts: ['a'] }, desired)).toBe(false)
})
it('the desired glob matches the real observed rollup context, not the push rollup', () => {
// Forgejo matches rule contexts as gobwas globs against the full context
// string — including the ` (event)` suffix — so the observed rollup is
// `CI (PR) / checks (pull_request)`. A literal `checks` would never match.
const glob = DESIRED_RULE.status_check_contexts[0]
const toRegex = (pattern: string) =>
new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace('*', '.*') + '$')
const match = toRegex(glob)
expect(match.test('CI (PR) / checks (pull_request)')).toBe(true)
expect(match.test('CI / checks (push)')).toBe(false)
expect(match.test('CI (PR) / checks')).toBe(true)
})
})

View file

@ -165,7 +165,10 @@ describe('forgejo-api', () => {
const statuses =
poll < 2
? [{ context: 'CI / static', status: 'pending' }]
: [{ context: 'CI / static', status: 'success' }]
: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
]
return ok({ statuses })
},
})
@ -191,7 +194,12 @@ describe('forgejo-api', () => {
head: { ref: 'x', sha: 'abc' },
})
}
return ok({ statuses: [{ context: 'CI / static', status: 'success' }] })
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
@ -214,7 +222,12 @@ describe('forgejo-api', () => {
head: { ref: 'x', sha: 'abc' },
})
}
return ok({ statuses: [{ context: 'CI / static', status: 'success' }] })
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
@ -252,6 +265,102 @@ describe('forgejo-api', () => {
expect(pr.isDraft).toBe(true)
})
it('waitForChecks keeps polling while the CI (PR) checks rollup has not posted (OPS61 race)', async () => {
let statusPoll = 0
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
if (String(url).includes('/pulls/')) {
return ok({
number: 4,
state: 'open',
merged: false,
mergeable: true,
head: { ref: 'x', sha: 'abc' },
})
}
statusPoll += 1
// First snapshots: every POSTED status is green — but jobs that were
// not scheduled yet (docs-guards/unit/checks) posted nothing. The
// rollup is the only context that exists once the WHOLE cascade
// settled; without the gate this snapshot looks green and merges red
// (the PR #52 incident).
const statuses =
statusPoll < 3
? [{ context: 'CI / static', status: 'success' }]
: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
]
return ok({ statuses })
},
})
const pr = await api.waitForChecks(4, { pollMs: 1 })
expect(pr).toBeTruthy()
expect(statusPoll).toBe(3)
})
it('waitForChecks throws when the CI (PR) checks rollup reports failure', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
if (String(url).includes('/pulls/')) {
return ok({
number: 4,
state: 'open',
merged: false,
mergeable: true,
head: { ref: 'x', sha: 'abc' },
})
}
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'failure' },
],
})
},
})
await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/Checks falharam/)
})
it('waitForChecks does not treat mergeable=false as conflict before the rollup settles (OPS61)', async () => {
let poll = 0
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
if (String(url).includes('/pulls/')) {
poll += 1
const settled = poll >= 3
return ok({
number: 4,
state: 'open',
merged: false,
mergeable: settled ? true : false,
head: { ref: 'x', sha: 'abc' },
})
}
return ok({
statuses:
poll < 3
? [{ context: 'CI / static', status: 'success' }]
: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
const pr = await api.waitForChecks(4, { pollMs: 1 })
expect(poll).toBe(3)
expect(pr.mergeable).toBe(true)
})
it('autoMerge verifies the merge by re-reading the PR (POST answers 200 + empty body)', async () => {
const calls: string[] = []
let poll = 0
@ -271,7 +380,12 @@ describe('forgejo-api', () => {
head: { ref: 'x', sha: 'abc' },
})
}
return ok({ statuses: [{ context: 'CI / static', status: 'success' }] })
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
@ -305,7 +419,12 @@ describe('forgejo-api', () => {
head: { ref: 'x', sha: 'abc' },
})
}
return ok({ statuses: [{ context: 'CI / static', status: 'success' }] })
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
@ -330,7 +449,12 @@ describe('forgejo-api', () => {
head: { ref: 'x', sha: 'abc' },
})
}
return ok({ statuses: [{ context: 'CI / static', status: 'success' }] })
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})