fix(OPS64): pin do waitForChecks contra rollup mentiroso + docs — OPS62 eliminou o secundário estruturalmente
All checks were successful
CI (PR) / checks (pull_request) Successful in 3m5s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 4m40s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13s

O sintoma primário da Issue (deadlock do waitForChecks no próprio pending)
já tinha sido entregue pelo OPS61. O secundário — o rollup `checks` reportava
SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o
resultado do ÚLTIMO shard como o parent de matrix no `needs` (runs 604/607/610;
o `needs.<job>.results` do GitHub não existe neste runner) — foi eliminado
estruturalmente pelo OPS62 (mergeado durante a execução): ci-pr.yml e ci.yml
viraram jobs únicos sequenciais, sem matrix/shard, e o status do job `checks`
já é o veredito honesto da cascata.

O que este item entrega: (a) teste-pino no waitForChecks (cenário run 607:
rollup verde + shard vermelho → o CLI LANÇA "Checks falharam" — nunca confia
no rollup sozinho; invariante de defesa válido e que pegaria qualquer matrix
que volte); (b) docs honestas (agent-pr-workflow.mdc, AGENT-OPS.md) com a
narrativa do parent de matrix e da eliminação estrutural pelo OPS62. O script
ci-rollup-check.mjs e os edits de workflow planejados foram descartados no
gate (redundantes pós-OPS62). Unit 2392 verdes; gates locais ok.
This commit is contained in:
Francisco Solla 2026-08-18 20:02:34 -03:00
parent 80062078b6
commit ccfa60c1f2
6 changed files with 153 additions and 2 deletions

View file

@ -58,6 +58,16 @@ Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
every ci-pr job; on the server the rule matches it as a glob,
`CI (PR) / checks*`)
OPS64: the rollup is structurally honest since OPS62 — ci-pr.yml is a SINGLE
sequential job (`checks`), so its own status IS the cascade verdict (no
matrix, no shards, no parent aggregation to lie). The historical bug (Forgejo
9.0.3 reporting the LAST matrix shard's result as the parent's in `needs`,
posting `checks` SUCCESS with an e2e shard red — runs 604/607/610) is gone by
construction; the `needs.<job>.results` array of GitHub does not exist in this
runner, so any future matrix must NOT aggregate via `needs`. The safety-net
CLI pins the defense: it never trusts the rollup alone (scans individual
statuses; run-607 scenario unit-tested in `forgejoApi.unit.spec.ts`).
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
**There is no deploy from PR CI.** The `ci.yml` verifier on `main` runs the full suite in its fixed 30-minute window and, on a production change, the `deploy` job publishes to the homeserver (`jorgesolla1313.com.br`, self-hosted) — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
@ -79,7 +89,10 @@ equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
`CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
postam status; o próprio status do job do safety net é `pending` durante o run
e **nunca** entra no veredito). A regra de branch protection no servidor
e **nunca** entra no veredito). O CLI nunca confia no rollup sozinho: varre
todos os statuses individuais e falha em qualquer `failure`/`error` — um shard
de e2e vermelho bloqueia o merge mesmo com o rollup verde (OPS64; run 607).
A regra de branch protection no servidor
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito
— inclusive merges manuais via API: foi um merge manual que colocou a PR #52
em main com o CI vermelho (o CLI nunca mergeou sozinho antes do OPS61; todo

View file

@ -93,7 +93,7 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
`POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub): o `scripts/agent-pool.mjs` o ignora quando presente; não criar no Forgejo. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do Forgejo.
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 (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
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 (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). **OPS64:** o CLI nunca confia no rollup sozinho — varre os statuses individuais e falha em qualquer `failure`/`error` (pin em `forgejoApi.unit.spec.ts`, cenário run 607: rollup verde com shard de e2e vermelho → throw). Com o desenho de job único do OPS62 (sem matrix, sem shards) o status do job `checks` já é o veredito honesto da cascata — o bug de agregação do parent de matrix deixou de existir estruturalmente. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) 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; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.

View file

@ -62,6 +62,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`.
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- <sha>"`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-<sha>`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.

View file

@ -0,0 +1,103 @@
# Impl: OPS64 — forgejo-pr-automerge: waitForChecks conta o próprio status pendente (deadlock → timeout, merge nunca acontece) + rollup `checks` sem agregação de shards
Status: em execução (entregue — ver desfecho no final)
Atualizado em: 2026-08-18
Issue: #62
Intenção: body da Issue (sem plano de intenção separado — body é a spec)
Appetite restante: herdado (~1 dia de ops)
## Leitura da intenção
- **Outcome:** (primário) o safety net `ready-automerge` deixa de se auto-bloquear no próprio status `pending` e mergea PRs com CI verde; (secundário) o rollup `checks` do `ci-pr.yml` (e o do `ci.yml`, que gateia o deploy) deixa de reportar SUCCESS com um shard de e2e vermelho — o gate do servidor (branch protection) e o UI param de mentir.
- **O que NÃO negociar:** merge só com CI verde de verdade (a classe OPS52 — merge manual/automático com shard vermelho — não pode voltar); `waitForChecks` continua ignorando o próprio contexto; nada de fork rodando código com o PAT do repo (guarda do OPS61); sem edição de Issues `in-progress` de terceiros; nada de prod DB.
- **O que reavaliar:** a intenção lista três correções possíveis para o primário (filtrar próprio context / `merge_when_checks_succeed` nativo / timeout > 40 min) — **a evidência mostra que o primário já foi entregue pelo OPS61** (issue criada antes do OPS61 subir); o trabalho real do OPS64 é o **secundário** (agregação de shards no rollup), que está vivo e medido.
## Achados da exploração (evidência ao vivo 2026-08-18)
1. **Primário (deadlock do próprio pending) — JÁ ENTREGUE pelo OPS61** (`8b989306`, em `main`): `waitForChecks` filtra contextos `PR Ready + auto-merge` (`verdicts`), gateia no rollup `CI (PR) / checks` presente + success, e o merge POST usa `FORGEJO_API_TOKEN` (409 do pre-receive). **Provado vivo:** runs 710 (PR #67) e 712 (PR #70) do `ready-automerge` terminaram `success` e os PRs foram mergeados no momento do fim do run (21:32:24/21:35:24 vs fim 21:32:27/21:35:31) — o CLI mergeou. O timeout já subiu para 45 min (CLI) / 55 min (workflow). A opção "timeout > 40 min" da intenção também já está entregue.
2. **Secundário (rollup `checks` mente) — VIVO e medido em 5 runs:** o resultado do job-pai de matrix no contexto `needs` do Forgejo 9.0.3 (gitea-1.22.0) é o resultado do **último shard a reportar**, não um AND sobre os shards:
- runs 604/607/610 (PR #55/#56/#57, ci-pr pré-OPS61 — mesmo jq de hoje): e2e (1)=failure, e2e (2)=success → `checks`=SUCCESS (shard vermelho mascarado);
- run 716 (PR #71, pós-OPS61): e2e (1)=success, e2e (2)=failure → `checks`=failure (o vermelho era o último — pegou por acaso);
- run 638: e2e (1)=failure, e2e (2)=success → `checks`=failure **só porque int e build também falharam** (o e2e vermelho continuou mascarado).
Ou seja: o gate `CI (PR) / checks*` do servidor e o rollup jq (`all(.value.result == "success" or "skipped")`) passam com um shard vermelho quando o último shard é verde.
3. **Por que o CLI ainda é seguro hoje:** `waitForChecks` varre **todos** os statuses individuais (`failed` inclui `CI (PR) / e2e (1)` = failure → throw). Foi por isso que no run 607 "só o waitForChecks pegou o vermelho". O buraco é o **gate do servidor** (branch protection satisfeita por rollup verde) + o UI — e o `deploy` do `ci.yml` (mesmo jq) rodaria com shard vermelho em main.
4. **Não existe `needs.<matrix>.results` no runner do Forgejo 9.0.3:** o `Needs` do act (gitea/act `pkg/exprparser/interpreter.go`) tem só `Outputs` e `Result` — o contrato `.results` do GitHub não existe aqui. A correção do rollup **não pode** vir do contexto `needs`.
5. **`merge_when_checks_succeed` existe neste servidor** (swagger + fonte gitea 1.22: `ScheduleAutoMerge` → `IsPullCommitStatusPass`), mas o OPS50/61 escolheu poll+merge e ele **funciona** (achado 1). Além disso o auto-merge nativo gateia nos contextos exigidos da branch protection — o mesmo rollup mentiroso — então **não** corrigiria o secundário; e o `mergeable` do servidor também usa o mesmo `MergeRequiredContextsCommitStatus`. Decisão: manter poll+merge; corrigir o rollup.
6. **Shards já postam status individuais:** cada shard do e2e posta `CI (PR) / e2e (N) (pull_request)` (e `CI / e2e (N) (push)` no main) — o dado confiável é o status do commit, não o parent do `needs`. É a mesma fonte que o `waitForChecks` usa (`listCommitStatuses`).
7. **Risco RCE ao colocar PAT no ci-pr.yml:** o `ci-pr.yml` roda em PRs de fork (repo público, sem guard no workflow). Injetar `FORGEJO_API_TOKEN` no job `checks` **sem** o guard same-repo seria código do fork rodando com o PAT (a mesma classe que o OPS61 guardou nos pós-merge). Qualquer uso de PAT no `ci-pr.yml` exige `if: head.repo.full_name == github.repository` no job.
## Abordagem recomendada
```mermaid
flowchart LR
A[rollup checks: jq sobre needs parent de matrix MENTE] --> B[novo script ci-rollup-check.mjs lê statuses do sha via API]
C[waitForChecks já varre statuses individuais - seguro] --> D[nada a mudar no CLI]
B --> E[ci-pr: fork guard + FORGEJO_API_TOKEN + script]
B --> F[ci.yml: FORGEJO_API_TOKEN + script - gate do deploy honesto]
G[testes unit: veredito do script + waitForChecks com rollup verde e shard vermelho] --> H[PR Closes #62]
```
**Opções consideradas:**
- **A — rollup lê os statuses do commit via API (`listCommitStatuses`), com o mesmo prefixo do workflow (`CI (PR) / `, `CI / `) e excluindo o próprio contexto (`…/ checks`):** veredito honesto por construção, mesma fonte do `waitForChecks`, funciona para o `ci-pr.yml` e o `ci.yml` (gate do deploy).
- B — `needs.<matrix>.results` (contrato GitHub): **inexistente** no act do Forgejo 9.0.3 (fonte, achado 4).
- C — `merge_when_checks_succeed` nativo (opção da intenção): existe no servidor, mas gateia nos contextos exigidos (rollup mentiroso) — não corrige o secundário; poll+merge atual já funciona (achado 1).
- D — quebrar a matrix em jobs explícitos (e2e-1/e2e-2): mexe no contrato OPS34 (`e2eShardConfig` pinado por unit test `shard: [1, 2]`), refatora o classificador de escopo; não é o dono do problema (o dono é o veredito do rollup).
- E — timeout > 40 min (opção da intenção): já entregue (45/55); não corrige a mentira do rollup.
- F — deixar como está (rollup pode mentir, "o CLI pega"): deixa o gate do servidor e o `deploy` vulneráveis à classe OPS52.
**Recomendação:** A — porque é o único caminho que (a) usa a mesma fonte que o CLI já confia (statuses do commit — não o parent de matrix), (b) corrige os dois rollups (PR e main/deploy), (c) não mexe no contrato OPS34, (d) é testável como função pura. O PAT no `ci-pr.yml` entra **com o fork guard** no job `checks` (achado 7).
**Rejeitadas:** B (não existe no runner — falha silenciosa ou dependência de upgrade); C (não corrige o secundário — o auto-merge nativo confiaria no rollup mentiroso; e o poll+merge provado continua); D (refatora contrato OPS34 sem necessidade); E (já entregue, não é fix); F (classe OPS52 no gate do servidor + deploy vermelho em main).
### Componentes / mudanças
- **`scripts/lib/ci-rollup-check.mjs`** (novo, puro, zero-dep): `rollupVerdict(statuses, { prefix, ownPrefix })` → `{ ok, failed: string[], pending: string[] }` — falha (exit 1) se qualquer contexto com o prefixo do workflow (excluindo o próprio rollup) for `failure`/`error`; falha também se houver `pending` (cascata não assentada — fail-closed); falha se a lista vier vazia (API indisponível/estranho — nunca "verde por falta de dado"); success apenas quando todos os demais contextos do workflow estiverem `success` (skipped posta como success, observado). Reusa `listCommitStatuses` do `forgejo-api.mjs`.
- **`scripts/ci-rollup-check.mjs`** (novo, CLI plain Node, padrão `forgejo-pr-automerge.mjs`): `--sha <head-sha> --prefix "CI (PR) / " --own-prefix "CI (PR) / checks"` (no `ci.yml`: `"CI / "` / `"CI / checks"`); imprime os contextos que falharam e sai 1; verde → 0.
- **`.forgejo/workflows/ci-pr.yml`** (job `checks`): `if:` ganha o fork guard (`github.event.pull_request.head.repo.full_name == github.repository`, pré-checkout — mesmo padrão do `ready-automerge`); `env: FORGEJO_API_TOKEN`; steps ganham `actions/checkout@v5` + `actions/setup-node@v5` (node 24, `package-manager-cache: false`) + `node scripts/ci-rollup-check.mjs --sha "${{ github.event.pull_request.head.sha }}" --prefix "CI (PR) / " --own-prefix "CI (PR) / checks"`; o passo jq do `needs` sai (o script o substitui — fonte única). `needs` e `if: always()` permanecem (o job só roda depois da cascata toda — a ordenação que garante "assentou").
- **`.forgejo/workflows/ci.yml`** (job `checks`): idem, sem fork guard (evento push/dispatch não tem fork); `--sha "${{ github.sha }}"`, prefixo `CI / `. Com isso o `deploy` (que `needs: [checks]`) nunca roda com shard de e2e vermelho em main.
- **`tests/unit/forgejoApi.unit.spec.ts`**: novo caso pinando o cenário run-607 — rollup `CI (PR) / checks` = success **com** `CI (PR) / e2e (1)` = failure → `waitForChecks` lança "Checks falharam" (o CLI nunca confia no rollup sozinho).
- **`tests/unit/ciRollupCheck.unit.spec.ts`** (novo): veredito puro — (a) shard vermelho + rollup próprio pending → fail (o run 607 do passado, agora no rollup); (b) shard (2) vermelho → fail (run 716); (c) tudo success → ok; (d) contexto do safety net (`PR Ready + auto-merge`) pending → ignorado (não casa o prefixo); (e) próprio contexto pending → ignorado; (f) lista vazia → fail; (g) variante `CI / ` do main; (h) `skipped` postado como success → ok.
- **Docs:** `.agents/rules/agent-pr-workflow.mdc` (seção "Required checks": rollup agora verifica os statuses individuais via API — o parent de matrix do Forgejo não agrega; nota do fork guard: PRs de fork ficam sem o status `checks` → merges de fork bloqueados pelo servidor, hardening intencional), `docs/AGENT-OPS.md` (linha da branch protection + tabela de workflows), header dos dois workflows, `docs/changelog/2026-08-18-ops64.md` + `pnpm changelog:build`.
- **Migration:** sem migration (não toca schema/DB).
- **Access / Consent:** N/A.
- **UI:** N/A (A).
## Fases verificáveis
1. **Script + veredito puro** — `scripts/lib/ci-rollup-check.mjs` + CLI + unit tests; `pnpm test:unit` local.
2. **Workflows** — `ci-pr.yml` e `ci.yml` com o novo passo; diff completo no PR da entrega.
3. **Gates** — `pnpm gate:fast` local (lint/format/typecheck/knip/cycles/unit); e2e roda no CI (OPS59). Push via `pnpm push`; PR `Closes #62`. **Verificação ao vivo na própria PR:** o run do `ready-automerge` da PR prova o primário de novo (já provado no OPS61); o novo rollup proof na PR — se um shard falhar, `checks` fica vermelho (e o servidor bloqueia), se tudo verde, mergea.
## Rabbit holes / Não escopo (engenharia)
- Não trocar poll+merge por `merge_when_checks_succeed` nativo (achado 5 — não corrige o secundário; poll+merge provado; trocar é item futuro se algum dia o upgrade do Forgejo mudar o contrato).
- Não consertar o parent de matrix do Forgejo (bug upstream do runner; a correção é viver sem ele).
- Não refatorar a matrix e2e (OPS34) nem o classificador de escopo.
- Não adicionar o fork guard aos demais jobs do `ci-pr.yml` (não têm segredos; fora do escopo).
- Não mexer no `waitForChecks` além do teste novo (o CLI já está correto — achado 3).
## Riscos e mitigação
- **PRs de fork perdem o status `checks`** (fork guard no job): merges de fork ficam bloqueados pelo servidor (contexto exigido nunca posta). Hardening intencional e alinhado à política atual ("a merged fork PR would deploy" — o `ready-automerge` já exclui forks); documentado no changelog. Se um dia um merge de fork for legítimo, é o escape documentado do OPS61 (ajustar a regra temporariamente).
- **API indisponível no job `checks` → rollup vermelho:** fail-closed (nunca verde por falta de dado); o job vermelha e o PR fica bloqueado até a infra voltar — visível, não silencioso. O mesmo runner self-hosted que roda os jobs serve a API (mesma rede), e o `ready-automerge` já depende dela.
- **Race status×rollup (status do último shard postado depois do `checks` começar):** o job `checks` só roda após `needs` da cascata toda (a ordenação do workflow garante "assentou"), e o runner posta o status no fim de cada job — o window é o mesmo que o jq atual já tinha; o `waitForChecks` (poll) continua sendo o backstop do merge.
- **O script roda o código do head no job `checks` (same-repo):** mesmo nível de confiança do `ready-automerge` (que também roda script do head com o PAT) — same-repo tem write de qualquer forma.
## Desfecho (gate com o humano — OPS62 mergeou em main durante a execução)
O OPS62 (mergeado às 23:32Z enquanto este item rodava) reescreveu ci-pr.yml e
ci.yml como **jobs únicos sequenciais** — sem matrix, sem shards. O bug de
agregação do parent de matrix que este item atacava **deixou de existir
estruturalmente**: o status do job `checks` agora É o veredito honesto da
cascata. Decisão do gate: **descartar** os edits de workflow e o script
`ci-rollup-check.mjs` (redundantes pós-OPS62), **manter** o teste-pino do
`waitForChecks` (run 607: rollup verde + shard vermelho → CLI lança) e as docs
honestas. Entregue: pin + docs + changelog.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (primário já entregue pelo OPS61; secundário eliminado estruturalmente pelo OPS62; o CLI segue com a defesa individual de statuses, pinada por teste)
- [x] Invariantes AGENTS/engineering-standards (sem código morto — script descartado; docs honestas — a mentira do parent de matrix documentada)
- [x] Testes de domínio previstos (waitForChecks run-607 pinado em `forgejoApi.unit.spec.ts`)
- [x] Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim)

View file

@ -332,6 +332,38 @@ describe('forgejo-api', () => {
await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/Checks falharam/)
})
it('waitForChecks throws when a matrix shard is red even if the rollup is green (run 607, OPS64)', async () => {
// Forgejo reports the LAST shard's result as the matrix parent result, so
// the ci-pr `checks` rollup job can post SUCCESS with e2e (1) red (runs
// 604/607/610). The CLI must NEVER trust the rollup alone: it scans the
// individual shard statuses (`CI (PR) / e2e (1)` = failure) and refuses to
// merge — this is what kept PR #55 out of main red in run 607.
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
if (String(url).includes('/pulls/')) {
return ok({
number: 55,
state: 'open',
merged: false,
mergeable: true,
head: { ref: 'OPS59-x', sha: 'abc' },
})
}
return ok({
statuses: [
{ context: 'CI / static', status: 'success' },
{ context: 'CI (PR) / e2e (1) (pull_request)', status: 'failure' },
{ context: 'CI (PR) / e2e (2) (pull_request)', status: 'success' },
{ context: 'CI (PR) / checks (pull_request)', status: 'success' },
],
})
},
})
await expect(api.waitForChecks(55, { pollMs: 1 })).rejects.toThrow(/Checks falharam/)
})
it('waitForChecks ignores the safety-net own status, pending for the whole run (OPS61 finding)', async () => {
let statusPoll = 0
const api = createApi({