From ac11afecf3f168436e2605004bde7aba96097e71 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 18 Aug 2026 16:39:05 -0300 Subject: [PATCH] =?UTF-8?q?ops(OPS61):=20flip=20de=20issues=20p=C3=B3s-mer?= =?UTF-8?q?ge=20com=20PAT=20+=20gate=20do=20rollup=20checks=20+=20branch?= =?UTF-8?q?=20protection=20real=20no=20Forgejo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .agents/rules/agent-pr-workflow.mdc | 14 +- .../workflows/agent-pr-ready-automerge.yml | 5 +- .../workflows/issue-done-on-main-merge.yml | 9 +- .../plan-issue-ready-on-main-merge.yml | 8 ++ README.md | 2 +- docs/AGENT-OPS.md | 44 +++--- docs/CHANGELOG-AGENTS.md | 1 + docs/CUTOVER-MAIN-ONLY.md | 2 +- docs/changelog/2026-08-18-ops61.md | 1 + ...s-merge-labels-e-branch-protection-impl.md | 94 ++++++++++++ scripts/configure-branch-protection.mjs | 69 ++++++--- scripts/forgejo-issue-transition.mjs | 17 ++- scripts/forgejo-pr-automerge.mjs | 4 + scripts/lib/branch-protection.mjs | 60 ++++++++ scripts/lib/forgejo-api.mjs | 47 +++++- tests/unit/branchProtection.unit.spec.ts | 53 +++++++ tests/unit/forgejoApi.unit.spec.ts | 136 +++++++++++++++++- 17 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 docs/changelog/2026-08-18-ops61.md create mode 100644 docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection-impl.md create mode 100644 scripts/lib/branch-protection.mjs create mode 100644 tests/unit/branchProtection.unit.spec.ts diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index 7785a35d..7a66d4fc 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -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 diff --git a/.forgejo/workflows/agent-pr-ready-automerge.yml b/.forgejo/workflows/agent-pr-ready-automerge.yml index 6fffc95e..510388b7 100644 --- a/.forgejo/workflows/agent-pr-ready-automerge.yml +++ b/.forgejo/workflows/agent-pr-ready-automerge.yml @@ -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: diff --git a/.forgejo/workflows/issue-done-on-main-merge.yml b/.forgejo/workflows/issue-done-on-main-merge.yml index aefe95f0..52bbe6f2 100644 --- a/.forgejo/workflows/issue-done-on-main-merge.yml +++ b/.forgejo/workflows/issue-done-on-main-merge.yml @@ -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: diff --git a/.forgejo/workflows/plan-issue-ready-on-main-merge.yml b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml index 64438507..88b7c354 100644 --- a/.forgejo/workflows/plan-issue-ready-on-main-merge.yml +++ b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml @@ -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 diff --git a/README.md b/README.md index 6186fb6c..d192375c 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 7fd2e1ca..f7886ca6 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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 -- ` | 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 -- ` | 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. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 0613e7c3..efb3b1b3 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -39,6 +39,7 @@ 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=` 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):** **OPS60** — o sync do `pnpm db:seed:posts` ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs `S3_*` ativas, o modo sync exige `SEED_MEDIA_CONFIRM=1` (mesmo padrão `isTruthyEnv` do `MEDIA_RECOVER_CONFIRM` da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais `S3_*` de prod (all-or-nothing do `worktree-env.mjs`) rodando sync contra DB local sobrescrevia covers no bucket de **prod** (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem `S3_*` → disk) e `--dry-run` seguem sem flag (não escrevem); echo do alvo ganhou `Bucket`/`Endpoint` quando S3 ativo (paridade com o `media:recover`). Implementação no dono: `isTruthyEnv` (já existente da #37) + `resolveS3StorageEnv` — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md. +**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). (ops(OPS61): flip de issues pós-merge com PAT + gate do rollup checks + branch protection real no Forgejo) **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. diff --git a/docs/CUTOVER-MAIN-ONLY.md b/docs/CUTOVER-MAIN-ONLY.md index b8e057d7..b09ee62f 100644 --- a/docs/CUTOVER-MAIN-ONLY.md +++ b/docs/CUTOVER-MAIN-ONLY.md @@ -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`) diff --git a/docs/changelog/2026-08-18-ops61.md b/docs/changelog/2026-08-18-ops61.md new file mode 100644 index 00000000..45f29639 --- /dev/null +++ b/docs/changelog/2026-08-18-ops61.md @@ -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). diff --git a/docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection-impl.md b/docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection-impl.md new file mode 100644 index 00000000..47a828ed --- /dev/null +++ b/docs/plans/ops61-forgejo-contratos-merge-labels-e-branch-protection-impl.md @@ -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 diff --git a/scripts/configure-branch-protection.mjs b/scripts/configure-branch-protection.mjs index c946b32a..34c824d5 100644 --- a/scripts/configure-branch-protection.mjs +++ b/scripts/configure-branch-protection.mjs @@ -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)}`, +) diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/forgejo-issue-transition.mjs index d89bbea2..0dbee9c5 100644 --- a/scripts/forgejo-issue-transition.mjs +++ b/scripts/forgejo-issue-transition.mjs @@ -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 */ @@ -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) +} diff --git a/scripts/forgejo-pr-automerge.mjs b/scripts/forgejo-pr-automerge.mjs index 8997886b..a142769b 100644 --- a/scripts/forgejo-pr-automerge.mjs +++ b/scripts/forgejo-pr-automerge.mjs @@ -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) { diff --git a/scripts/lib/branch-protection.mjs b/scripts/lib/branch-protection.mjs new file mode 100644 index 00000000..3cd313c1 --- /dev/null +++ b/scripts/lib/branch-protection.mjs @@ -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' } +} diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs index 0ae7815b..8d5edc96 100644 --- a/scripts/lib/forgejo-api.mjs +++ b/scripts/lib/forgejo-api.mjs @@ -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)) } diff --git a/tests/unit/branchProtection.unit.spec.ts b/tests/unit/branchProtection.unit.spec.ts new file mode 100644 index 00000000..babaed5e --- /dev/null +++ b/tests/unit/branchProtection.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts index 55e51f9e..819dc44e 100644 --- a/tests/unit/forgejoApi.unit.spec.ts +++ b/tests/unit/forgejoApi.unit.spec.ts @@ -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' }, + ], + }) }, })