diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index 7a66d4fc..16d6a85b 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -86,9 +86,14 @@ There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` ve 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`). 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. +`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 +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 +merge Forgejo era manual). 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 é diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index f7886ca6..707cc125 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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 — 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). +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). Deploy (OPS53): o job `deploy` do `ci.yml` roda no host do runner (workstation) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy. Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index efb3b1b3..c845955c 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -41,6 +41,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **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-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 — achado ao vivo na própria entrega, PR #67)** O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro, então o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual, não do script). O gate novo espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min). A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado). + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); 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-`), **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. diff --git a/docs/changelog/2026-08-18-ops61.md b/docs/changelog/2026-08-18-ops61.md index 45f29639..1cf28410 100644 --- a/docs/changelog/2026-08-18-ops61.md +++ b/docs/changelog/2026-08-18-ops61.md @@ -1 +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). +**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 — achado ao vivo na própria entrega, PR #67)** O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro, então o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual, não do script). O gate novo espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min). A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado). diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs index 8d5edc96..d8b83781 100644 --- a/scripts/lib/forgejo-api.mjs +++ b/scripts/lib/forgejo-api.mjs @@ -291,19 +291,30 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { }), /** - * 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). + * The CI (PR) cascade rollup is green 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 + * pending can still miss jobs that were not scheduled yet. The rollup only + * exists once the whole cascade settled (it `needs` every job), so its + * absence keeps the wait going. + * + * The safety-net's OWN context (`PR Ready + auto-merge / …`) is posted as + * `pending` for the whole run and flips only at the end — it must never + * gate the verdict. Live finding (PR #67): the pre-OPS61 + * `pending.length === 0` gate was unsatisfiable for that reason, so the + * CLI never auto-merged anything — every Forgejo merge so far was manual + * (`merged_by` the owner), which is also what PR #52's red-CI merge was + * (manual, not a waitForChecks race). The branch-protection rule on the + * server is the real gate for manual merges too: the merge POST is + * rejected (405) while the required context is not green. + * + * `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 } = {}) => { @@ -312,12 +323,14 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { const pr = await api.getPullRequest(number) if (!pr || pr.state !== 'OPEN' || pr.merged || pr.isDraft) return pr const statuses = await api.listCommitStatuses(pr.head.sha) - const rollup = statuses.find((entry) => entry.context.startsWith('CI (PR) / checks')) + const verdicts = statuses.filter( + (entry) => !entry.context.startsWith('PR Ready + auto-merge'), + ) + const rollup = verdicts.find((entry) => entry.context.startsWith('CI (PR) / checks')) const cascadeSettled = Boolean(rollup && rollup.state === 'success') - const failed = statuses.filter( + const failed = verdicts.filter( (entry) => entry.state === 'failure' || entry.state === 'error', ) - const pending = statuses.filter((entry) => entry.state === 'pending') if (failed.length > 0) { const names = failed.map((entry) => entry.context).join(', ') throw new Error(`Checks falharam no PR #${number}: ${names}`) @@ -325,13 +338,13 @@ export const createApi = ({ base, token, repository, fetchImpl } = {}) => { 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 (cascadeSettled && 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, rollup=${ + `[pr#${number}] ${verdicts.length} status, rollup=${ rollup ? rollup.state : 'ausente' }, mergeable=${pr.mergeable ?? 'computando'} — aguardando…`, ) diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts index 819dc44e..d95a4fe8 100644 --- a/tests/unit/forgejoApi.unit.spec.ts +++ b/tests/unit/forgejoApi.unit.spec.ts @@ -265,7 +265,7 @@ describe('forgejo-api', () => { expect(pr.isDraft).toBe(true) }) - it('waitForChecks keeps polling while the CI (PR) checks rollup has not posted (OPS61 race)', async () => { + it('waitForChecks keeps polling while the CI (PR) checks rollup has not posted (OPS61 gate)', async () => { let statusPoll = 0 const api = createApi({ token: 'tok', @@ -283,8 +283,8 @@ describe('forgejo-api', () => { // 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). + // settled; without the gate a snapshot can look green before the + // cascade finished. const statuses = statusPoll < 3 ? [{ context: 'CI / static', status: 'success' }] @@ -327,6 +327,44 @@ describe('forgejo-api', () => { await expect(api.waitForChecks(4, { 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({ + 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 + // Forgejo posts the job's own commit status as pending at job start + // and flips it only at the end — the pre-OPS61 `pending.length === 0` + // gate was unsatisfiable and no PR was ever merged by the CLI (every + // Forgejo merge was manual). The own context must never gate. + return ok({ + statuses: [ + { + context: 'PR Ready + auto-merge / ready-automerge (pull_request)', + status: 'pending', + }, + { context: 'CI / static', status: 'success' }, + { context: 'CI (PR) / checks (pull_request)', status: 'success' }, + ], + }) + }, + }) + + const pr = await api.waitForChecks(4, { pollMs: 1 }) + + expect(pr).toBeTruthy() + expect(statusPoll).toBe(1) + }) + it('waitForChecks does not treat mergeable=false as conflict before the rollup settles (OPS61)', async () => { let poll = 0 const api = createApi({