ops(OPS61): flip de issues pós-merge com PAT + gate do rollup checks + branch protection real no Forgejo
- 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:
parent
bf4063b236
commit
ac11afecf3
17 changed files with 506 additions and 60 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<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):** **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
1
docs/changelog/2026-08-18-ops61.md
Normal file
1
docs/changelog/2026-08-18-ops61.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
60
scripts/lib/branch-protection.mjs
Normal file
60
scripts/lib/branch-protection.mjs
Normal 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' }
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
53
tests/unit/branchProtection.unit.spec.ts
Normal file
53
tests/unit/branchProtection.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue