fix(OPS64): rollup checks honesto por statuses do commit — Forgejo não agrega shards no parent de matrix
Some checks failed
CI (PR) / scope (pull_request) Successful in 33s
CI (PR) / lint (pull_request) Successful in 44s
CI (PR) / docs-guards (pull_request) Successful in 7s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
CI (PR) / format (pull_request) Successful in 55s
CI (PR) / typecheck (pull_request) Successful in 31s
CI (PR) / knip (pull_request) Successful in 36s
CI (PR) / cycles (pull_request) Successful in 39s
CI (PR) / unit (pull_request) Successful in 59s
CI (PR) / int (pull_request) Successful in 2m23s
CI (PR) / build (pull_request) Has been skipped
CI (PR) / e2e (1) (pull_request) Has been skipped
CI (PR) / e2e (2) (pull_request) Has been skipped
CI (PR) / checks (pull_request) Failing after 1s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 50m5s

O sintoma primário da Issue (deadlock do waitForChecks no próprio pending)
já tinha sido entregue pelo OPS61; o OPS64 fecha o secundário, que estava
vivo e medido: 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), e o `needs.<job>.results` do GitHub
não existe neste runner. Com isso o gate do servidor (branch protection) e o
deploy do ci.yml podiam passar com um shard vermelho.

O rollup `checks` (ci-pr.yml e ci.yml) agora lê os statuses do commit via API
(scripts/ci-rollup-check.mjs + lib pura, zero-dep, plain Node) — a mesma
fonte que o waitForChecks confia — filtrando pelo prefixo do workflow,
excluindo o próprio contexto e os downstreams legítimos (deploy, pending
enquanto o rollup roda), falhando fechado em failure/error/pending/vazio.
Fork guard + FORGEJO_API_TOKEN no job checks do ci-pr.yml (sem o guard, fork
rodaria o script do head com o PAT — RCE classe OPS61); PRs de fork ficam
não-mergeáveis no servidor (hardening). ci.yml usa --allow-empty em
workflow_dispatch (evento não posta commit status).

Testes: 12 specs do veredito (run 607/716 pinados, próprio pending ignorado,
safety net ignorado, deploy ignorado, vazio fail-closed) + waitForChecks
lança com rollup verde + shard vermelho. Unit 2360 verdes; gates locais ok.
This commit is contained in:
Francisco Solla 2026-08-18 20:02:34 -03:00
parent e6bd1d2688
commit 10c1eeaeda
11 changed files with 490 additions and 23 deletions

View file

@ -64,6 +64,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 verdict reads the **per-job commit statuses via the API**
(`scripts/ci-rollup-check.mjs`), not the `needs` context — Forgejo 9.0.3
reports the matrix parent's result as the LAST shard's result, so an
`all(needs…)` jq could post `checks` SUCCESS with an e2e shard red (runs
604/607/610; the `needs.<job>.results` array of GitHub does not exist in this
runner). Same source the safety-net CLI trusts; shards post their own statuses.
PRs de fork não postam o contexto `checks` (fork guard no job — o job roda o
script do head com o PAT) e ficam não-mergeáveis pelo servidor (hardening:
"a merged fork PR would deploy"; o auto-merge já os excluía).
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
**There is no deploy from CI.** The `ci.yml` verifier on `main` runs the full suite and stops (production is self-hosted on the homeserver — `jorgesolla1313.com.br` — deployed via an ops runbook outside this repo). Do not add a Vercel/Git-integration deploy gate.
@ -89,7 +99,10 @@ equivalente Forgejo do `gh pr merge --auto --rebase`). 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

@ -414,14 +414,31 @@ jobs:
- e2e
- docs-guards
- plans-only-closes
if: always()
# OPS64: fork guard obrigatório — este job injeta FORGEJO_API_TOKEN e roda
# o script do head; sem o guard, um PR de fork executaria o código do fork
# com o PAT (RCE, mesma classe do guard do ready-automerge). Consequência
# intencional: PRs de fork não postam o contexto `checks` → branch
# protection os mantém não-mergeáveis (hardening — "a merged fork PR would
# deploy"; o auto-merge já os excluía).
if: >-
always() &&
github.event.pull_request.head.repo.full_name == github.repository
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- name: All cascade checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
# OPS64: veredito por statuses do commit (mesma fonte do waitForChecks),
# não pelo parent de matrix do `needs` — Forgejo reporta o resultado do
# ÚLTIMO shard como resultado do parent (runs 604/607/610: e2e (1) red +
# e2e (2) green → checks green), e o `needs.<job>.results` do GitHub não
# existe neste runner. O job só roda depois da cascata inteira (`needs` +
# `if: always()`), então verde = cascata assentada e verde de verdade.
- name: All cascade checks green (statuses API)
run: node scripts/ci-rollup-check.mjs \
--sha "${{ github.event.pull_request.head.sha }}" \
--prefix "CI (PR) / " \
--own-prefix "CI (PR) / checks"

View file

@ -197,16 +197,31 @@ jobs:
runs-on: ubuntu-latest
needs: [static, int, build, e2e]
if: always()
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
steps:
- name: All main verifier checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
# OPS64: veredito por statuses do commit (mesma fonte do waitForChecks),
# não pelo parent de matrix do `needs` — Forgejo reporta o resultado do
# ÚLTIMO shard como resultado do parent (o deploy não pode rodar com um
# shard de e2e vermelho em main). O job `deploy` é downstream do rollup
# (needs: [checks]) e fica `pending` enquanto o checks roda — excluído
# via --ignore-prefix, nunca gateia. workflow_dispatch não posta commit
# status (gitea createCommitStatus retorna cedo nesse evento) — o
# --allow-empty deixa o gate passar sem statuses nesse canal manual
# (o push é o caminho canônico; o job deploy em dispatch continua o
# comportamento atual).
- name: All main verifier checks green (statuses API)
run: node scripts/ci-rollup-check.mjs \
--sha "${{ github.sha }}" \
--prefix "CI / " \
--own-prefix "CI / checks" \
--ignore-prefix "CI / deploy" \
--allow-empty "${{ github.event_name == 'workflow_dispatch' }}"
deploy:
name: deploy homeserver (teqo-1313)

View file

@ -71,8 +71,8 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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) |
| `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` por statuses API (OPS64; fork guard + PAT no job) |
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks` por statuses API (OPS64); job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) |
| `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 o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
@ -90,7 +90,7 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
| `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 (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 veredito do rollup (nos dois `ci.yml`/`ci-pr.yml`) lê os statuses individuais do commit via API (`scripts/ci-rollup-check.mjs`), não o parent de matrix do `needs` (o Forgejo reporta o resultado do último shard — um e2e vermelho podia passar com `checks` verde; runs 604/607/610). O job `checks` do `ci-pr.yml` roda com `FORGEJO_API_TOKEN` + fork guard (PRs de fork não postam o contexto e ficam não-mergeáveis — hardening). Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53): o job `deploy` do `ci.yml` roda no host do runner (workstation) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy. Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.

View file

@ -49,6 +49,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o rollup `checks` deixa de mentir sobre shards de e2e. 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 OPS64 entrega o secundário, que estava vivo e medido: o Forgejo 9.0.3 reporta o resultado do **último** shard como resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; run 716: vermelho no último → pegou por acaso), e o `needs.<job>.results` do GitHub **não existe** neste runner (act expõe só `Outputs`/`Result`). Com isso o gate do servidor (branch protection `CI (PR) / checks*`) e o job `deploy` do `ci.yml` podiam passar com um shard vermelho — a classe OPS52 reaberta no servidor (o CLI nunca mergeou vermelho: ele varre os statuses individuais — foi o que pegou o run 607). Correção: o veredito do rollup (`ci-pr.yml` e `ci.yml`) passou a ler os **statuses do commit via API** (`scripts/ci-rollup-check.mjs` + lib pura `scripts/lib/ci-rollup-check.mjs`, zero-dep, plain Node) — a mesma fonte que o `waitForChecks` já confia — filtrando pelo prefixo do workflow, excluindo o próprio contexto (`checks`) e os downstreams legítimos (`deploy`, que fica `pending` enquanto o rollup roda) e falhando fechado em `failure`/`error`/`pending`/lista vazia. O job `checks` do `ci-pr.yml` ganhou `FORGEJO_API_TOKEN` **+ fork guard** (sem o guard, um PR de fork rodaria o código do fork com o PAT — RCE classe OPS61); consequência intencional: PRs de fork não postam o contexto `checks` e ficam não-mergeáveis no servidor (hardening — "a merged fork PR would deploy"; o auto-merge já os excluía). No `ci.yml` o `--allow-empty` cobre `workflow_dispatch` (esse evento não posta commit status — gitea `createCommitStatus` retorna cedo; o canal manual mantém o comportamento atual). Testes: 12 specs novas em `tests/unit/ciRollupCheck.unit.spec.ts` (shard vermelho com rollup verde → fail — o run 607 do passado; shard (2) vermelho → fail — run 716; próprio pending ignorado; safety net de outro workflow ignorado; deploy pending ignorado via ignorePrefixes; lista vazia → fail; allowEmpty só em dispatch; skipped = success) + caso novo em `forgejoApi.unit.spec.ts` pinando que o `waitForChecks` lança com rollup verde + shard vermelho (nunca confia no rollup sozinho). Docs: `agent-pr-workflow.mdc` e `AGENT-OPS.md` (rollup por statuses, fork guard, hardening de fork). 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 rollup ao vivo (verde com cascata verde; shard vermelho → `checks` vermelho; 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 rollup `checks` deixa de mentir sobre shards de e2e. 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 OPS64 entrega o secundário, que estava vivo e medido: o Forgejo 9.0.3 reporta o resultado do **último** shard como resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; run 716: vermelho no último → pegou por acaso), e o `needs.<job>.results` do GitHub **não existe** neste runner (act expõe só `Outputs`/`Result`). Com isso o gate do servidor (branch protection `CI (PR) / checks*`) e o job `deploy` do `ci.yml` podiam passar com um shard vermelho — a classe OPS52 reaberta no servidor (o CLI nunca mergeou vermelho: ele varre os statuses individuais — foi o que pegou o run 607). Correção: o veredito do rollup (`ci-pr.yml` e `ci.yml`) passou a ler os **statuses do commit via API** (`scripts/ci-rollup-check.mjs` + lib pura `scripts/lib/ci-rollup-check.mjs`, zero-dep, plain Node) — a mesma fonte que o `waitForChecks` já confia — filtrando pelo prefixo do workflow, excluindo o próprio contexto (`checks`) e os downstreams legítimos (`deploy`, que fica `pending` enquanto o rollup roda) e falhando fechado em `failure`/`error`/`pending`/lista vazia. O job `checks` do `ci-pr.yml` ganhou `FORGEJO_API_TOKEN` **+ fork guard** (sem o guard, um PR de fork rodaria o código do fork com o PAT — RCE classe OPS61); consequência intencional: PRs de fork não postam o contexto `checks` e ficam não-mergeáveis no servidor (hardening — "a merged fork PR would deploy"; o auto-merge já os excluía). No `ci.yml` o `--allow-empty` cobre `workflow_dispatch` (esse evento não posta commit status — gitea `createCommitStatus` retorna cedo; o canal manual mantém o comportamento atual). Testes: 12 specs novas em `tests/unit/ciRollupCheck.unit.spec.ts` (shard vermelho com rollup verde → fail — o run 607 do passado; shard (2) vermelho → fail — run 716; próprio pending ignorado; safety net de outro workflow ignorado; deploy pending ignorado via ignorePrefixes; lista vazia → fail; allowEmpty só em dispatch; skipped = success) + caso novo em `forgejoApi.unit.spec.ts` pinando que o `waitForChecks` lança com rollup verde + shard vermelho (nunca confia no rollup sozinho). Docs: `agent-pr-workflow.mdc` e `AGENT-OPS.md` (rollup por statuses, fork guard, hardening de fork). 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 rollup ao vivo (verde com cascata verde; shard vermelho → `checks` vermelho; o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.

View file

@ -0,0 +1,92 @@
# 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: aprovado
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.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (primário já entregue pelo OPS61; secundário — rollup honesto — entregue aqui; classe OPS52 fechada no gate do servidor e no deploy)
- [ ] Invariantes AGENTS/engineering-standards (zero-dep scripts; fork nunca roda código com o PAT; docs honestas — a mentira do parent de matrix documentada)
- [ ] Testes de domínio previstos (unit: veredito do rollup ×8, waitForChecks run-607 pinado) onde o write/merge path muda
- [ ] Verificação ao vivo: rollup da própria PR da entrega verde com cascata verde; shard vermelho → `checks` vermelho (se algum flakear no run); deploy do main nunca roda com shard vermelho

View file

@ -0,0 +1,72 @@
/**
* Plain-Node CLI (no pnpm) — CI cascade rollup gate for the `checks` jobs of
* ci-pr.yml and ci.yml (OPS64).
*
* Reads the commit statuses of the head sha via the Forgejo API and fails when
* any of the workflow's own job statuses is failure/error (or the cascade
* never settles / the read comes back empty). Replaces the old
* `all(needs...)` jq, whose matrix parent result did not aggregate shards:
* Forgejo 9.0.3 reports the LAST shard's result, so `checks` could post
* SUCCESS with an e2e shard red (runs 604/607/610). The `needs.<job>.results`
* array of GitHub does not exist in this runner.
*
* The job still runs only after every cascade job finished (`needs` +
* `if: always()` in the workflow), so a green verdict means the whole cascade
* settled green — the commit statuses are the same source waitForChecks
* trusts for the merge.
*
* node scripts/ci-rollup-check.mjs \
* --sha <head-sha> \
* --prefix "CI (PR) / " \
* --own-prefix "CI (PR) / checks" \
* [--ignore-prefix "CI / deploy"] \
* [--allow-empty]
*/
import { rollupVerdict } from './lib/ci-rollup-check.mjs'
import { createApi } from './lib/forgejo-api.mjs'
const parseArgs = (argv) => {
const flags = {}
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
const next = argv[index + 1]
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
if (typeof next === 'string' && !next.startsWith('--')) index += 1
}
}
return flags
}
const flags = parseArgs(process.argv)
const sha = flags.sha
const prefix = flags.prefix
const ownPrefix = flags['own-prefix']
const ignorePrefixes = [flags['ignore-prefix']].filter(Boolean)
const allowEmpty = flags['allow-empty'] === true || flags['allow-empty'] === 'true'
if (!sha || !prefix || !ownPrefix) {
console.error(
'Usage: node scripts/ci-rollup-check.mjs --sha <sha> --prefix <prefix> --own-prefix <own-prefix> [--ignore-prefix <prefix>] [--allow-empty]',
)
process.exit(1)
}
const api = createApi({})
try {
const statuses = await api.listCommitStatuses(sha)
const verdict = rollupVerdict(statuses, { prefix, ownPrefix, ignorePrefixes, allowEmpty })
if (!verdict.ok) {
console.error(`[ci-rollup-check] ${verdict.reason}`)
process.exit(1)
}
console.log(
`[ci-rollup-check] ${allowEmpty && statuses.length === 0 ? 'sem statuses (evento sem commit status) — ok' : 'cascata verde'}`,
)
process.exit(0)
} catch (error) {
console.error(`[ci-rollup-check] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -0,0 +1,75 @@
/**
* Pure verdict for the CI cascade rollup jobs (`checks` in ci-pr.yml and
* ci.yml). Decides whether the workflow's cascade is green from the commit
* statuses of the head sha — the SAME source `waitForChecks` trusts — instead
* of the `needs` context.
*
* Why not `needs` (OPS64 finding, runs 604/607/610): Forgejo 9.0.3 reports the
* matrix parent's `result` as the LAST shard's result, not an AND over shards
* (e2e (1) red + e2e (2) green → parent green → the old `all(needs...)` jq
* posted `checks` SUCCESS with a red shard). The `needs.<job>.results` array
* of GitHub does not exist in this runner (gitea/act `Needs` has only
* `Outputs` and `Result`), so the aggregation cannot come from `needs` at all.
* Each matrix shard posts its OWN status (`CI (PR) / e2e (1)` = failure),
* which is what this verdict reads.
*
* `ownPrefix` excludes the rollup job's own context (posted `pending` while
* the job runs). `ignorePrefixes` excludes downstream jobs that legitimately
* stay `pending` while the rollup runs (e.g. the `deploy` job of ci.yml, which
* `needs` the `checks` job — its status is `pending` at rollup time and must
* never gate the verdict; the deploy only starts AFTER the rollup is green).
*
* Fail-closed: an empty relevant set fails unless `allowEmpty` (used by
* ci.yml on workflow_dispatch — that event posts no commit statuses, so the
* jq-verified `needs` cascade is the only signal and an empty read must not
* red the manual channel).
*
* @typedef {{ context: string, state: string }} RollupStatus
* @typedef {{ prefix: string, ownPrefix: string, ignorePrefixes?: string[], allowEmpty?: boolean }} RollupVerdictOptions
* @typedef {{ ok: boolean, failed: RollupStatus[], pending: RollupStatus[], reason?: string }} RollupVerdict
*/
/**
* @param {RollupStatus[]} [statuses]
* @param {RollupVerdictOptions} [options]
* @returns {RollupVerdict}
*/
export const rollupVerdict = (
statuses,
{ prefix, ownPrefix, ignorePrefixes = [], allowEmpty = false } = {},
) => {
const relevant = (statuses ?? []).filter(
(entry) =>
entry.context.startsWith(prefix) &&
!entry.context.startsWith(ownPrefix) &&
!ignorePrefixes.some((ignore) => entry.context.startsWith(ignore)),
)
if (relevant.length === 0) {
return allowEmpty
? { ok: true, failed: [], pending: [] }
: { ok: false, failed: [], pending: [], reason: 'nenhum status do workflow no sha' }
}
const failed = relevant.filter((entry) => entry.state === 'failure' || entry.state === 'error')
if (failed.length > 0) {
return {
ok: false,
failed,
pending: [],
reason: `status vermelhos: ${failed.map((entry) => entry.context).join(', ')}`,
}
}
const pending = relevant.filter((entry) => entry.state === 'pending')
if (pending.length > 0) {
return {
ok: false,
failed: [],
pending,
reason: `status pendentes: ${pending.map((entry) => entry.context).join(', ')}`,
}
}
return { ok: true, failed: [], pending: [] }
}

View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import { rollupVerdict } from '../../scripts/lib/ci-rollup-check.mjs'
const CI_PR = 'CI (PR) / '
const OWN = 'CI (PR) / checks'
const status = (context: string, state: string) => ({ context, state })
describe('rollupVerdict (OPS64)', () => {
it('reports ok when every workflow status is success', () => {
const verdict = rollupVerdict(
[
status('CI (PR) / lint (pull_request)', 'success'),
status('CI (PR) / e2e (1) (pull_request)', 'success'),
status('CI (PR) / e2e (2) (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(true)
})
it('fails when a matrix shard is failure even if the parent would report green (run 604/607/610)', () => {
// Forgejo reports the LAST shard's result as the matrix parent result, so
// the old `all(needs...)` jq passed with e2e (1) red — the exact hole this
// verdict closes. The shard's own status is the ground truth.
const verdict = rollupVerdict(
[
status('CI (PR) / lint (pull_request)', 'success'),
status('CI (PR) / e2e (1) (pull_request)', 'failure'),
status('CI (PR) / e2e (2) (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(false)
expect(verdict.failed.map((entry) => entry.context)).toContain(
'CI (PR) / e2e (1) (pull_request)',
)
})
it('fails when the failing shard is the last one (run 716)', () => {
const verdict = rollupVerdict(
[
status('CI (PR) / e2e (1) (pull_request)', 'success'),
status('CI (PR) / e2e (2) (pull_request)', 'failure'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(false)
expect(verdict.failed.map((entry) => entry.context)).toContain(
'CI (PR) / e2e (2) (pull_request)',
)
})
it('ignores the rollup own pending status (posted while the job runs)', () => {
const verdict = rollupVerdict(
[
status('CI (PR) / checks (pull_request)', 'pending'),
status('CI (PR) / e2e (1) (pull_request)', 'success'),
status('CI (PR) / e2e (2) (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(true)
})
it('ignores contexts of other workflows on the same sha (the safety net)', () => {
const verdict = rollupVerdict(
[
status('PR Ready + auto-merge / ready-automerge (pull_request)', 'pending'),
status('CI (PR) / e2e (1) (pull_request)', 'success'),
status('CI (PR) / e2e (2) (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(true)
})
it('ignores downstream pending contexts via ignorePrefixes (ci.yml deploy)', () => {
const verdict = rollupVerdict(
[
status('CI / static (push)', 'success'),
status('CI / e2e (1) (push)', 'success'),
status('CI / deploy (push)', 'pending'),
],
{ prefix: 'CI / ', ownPrefix: 'CI / checks', ignorePrefixes: ['CI / deploy'] },
)
expect(verdict.ok).toBe(true)
})
it('fails on pending siblings (cascade not settled — fail-closed)', () => {
const verdict = rollupVerdict(
[
status('CI (PR) / e2e (1) (pull_request)', 'pending'),
status('CI (PR) / e2e (2) (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(false)
expect(verdict.pending.map((entry) => entry.context)).toContain(
'CI (PR) / e2e (1) (pull_request)',
)
})
it('fails on error state', () => {
const verdict = rollupVerdict([status('CI (PR) / lint (pull_request)', 'error')], {
prefix: CI_PR,
ownPrefix: OWN,
})
expect(verdict.ok).toBe(false)
})
it('fails closed on an empty relevant set (no statuses for the sha)', () => {
const verdict = rollupVerdict([], { prefix: CI_PR, ownPrefix: OWN })
expect(verdict.ok).toBe(false)
})
it('allows an empty set only when allowEmpty (ci.yml workflow_dispatch)', () => {
const verdict = rollupVerdict([], {
prefix: 'CI / ',
ownPrefix: 'CI / checks',
allowEmpty: true,
})
expect(verdict.ok).toBe(true)
})
it('accepts skipped jobs as success (posted with "Has been skipped")', () => {
const verdict = rollupVerdict(
[
status('CI (PR) / build (pull_request)', 'success'),
status('CI (PR) / int (pull_request)', 'success'),
],
{ prefix: CI_PR, ownPrefix: OWN },
)
expect(verdict.ok).toBe(true)
})
})

View file

@ -327,6 +327,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({