fix(OPS71-FLIP): flip pós-merge silencioso no GitHub — PAT no arm do auto-merge, dispatch de recuperação, close da Issue, retry 410
Root cause (Issue #119): auto-merge armado com GITHUB_TOKEN mergea como github-actions[bot] e o anti-recursion do GitHub nunca cria runs para o evento closed — os flips pós-merge (issue-done / plan-issue-ready) não rodavam em silêncio (PR #746). Arm agora usa AUTOMERGE_PAT (fail-closed); workflow_dispatch de recuperação nos dois flips; forgejo-issue-transition fecha a Issue (PATCH state=closed); forgejo-api retenta 410 (proxy respondeu antes da origem) e loga corpo nginx de forma compacta.
This commit is contained in:
parent
6cf5ea5885
commit
e504491dd8
14 changed files with 407 additions and 39 deletions
|
|
@ -54,4 +54,11 @@ jobs:
|
|||
# guaranteed to be injected on every event path; passing it via the
|
||||
# documented context is the robust form.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# OPS71-FLIP: arming with the built-in GITHUB_TOKEN makes the merge
|
||||
# happen as github-actions[bot], and GitHub's anti-recursion never
|
||||
# creates workflow runs for events triggered by GITHUB_TOKEN actions
|
||||
# — the post-merge `closed` flips would silently never run (PR #746).
|
||||
# AUTOMERGE_PAT (real user PAT) attributes the merge to a user, so
|
||||
# the `closed` event creates runs again. Fail-closed in the CLI.
|
||||
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
|
||||
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
|
|||
58
.github/workflows/issue-done-on-main-merge.yml
vendored
58
.github/workflows/issue-done-on-main-merge.yml
vendored
|
|
@ -1,30 +1,45 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# OPS71: the PR lives on GitHub; the ISSUES stay on the Forgejo tracker. The
|
||||
# job passes the PR body via PR_BODY (GitHub event payload) and
|
||||
# scripts/forgejo-issue-transition.mjs flips labels/comments on the Forgejo
|
||||
# API with the FORGEJO_API_TOKEN PAT (the built-in GITHUB_TOKEN cannot touch
|
||||
# Forgejo issues — OPS61). The fork guard is the same class as
|
||||
# agent-pr-ready-automerge.yml: on a merged fork PR the checkout pulls the
|
||||
# fork's own script, which would run with the repo PAT (RCE).
|
||||
# scripts/forgejo-issue-transition.mjs flips labels/comments and closes the
|
||||
# issue on the Forgejo API with the FORGEJO_API_TOKEN PAT (the built-in
|
||||
# GITHUB_TOKEN cannot touch Forgejo issues — OPS61). The fork guard is the
|
||||
# same class as agent-pr-ready-automerge.yml: on a merged fork PR the checkout
|
||||
# pulls the fork's own script, which would run with the repo PAT (RCE).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path. The `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN —
|
||||
# GitHub's anti-recursion; live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same flip.
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
|
|
@ -33,5 +48,32 @@ jobs:
|
|||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
MERGED="$(gh pr view "$PR" --json merged -q .merged)"
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "PR #$PR não mergeada — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
|
|
|
|||
|
|
@ -11,26 +11,35 @@
|
|||
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
|
||||
# which would run with the repo PAT (RCE — same class as
|
||||
# agent-pr-ready-automerge.yml).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path — the `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN;
|
||||
# GitHub's anti-recursion, live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same promotion.
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
contains(github.event.pull_request.body, 'Related #') ||
|
||||
contains(github.event.pull_request.body, 'related #') ||
|
||||
contains(github.event.pull_request.body, 'RELATED #')
|
||||
)
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.body, 'Related #') || contains(github.event.pull_request.body, 'related #') || contains(github.event.pull_request.body, 'RELATED #'))) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
|
|
@ -38,7 +47,6 @@ jobs:
|
|||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable
|
||||
|
|
@ -48,5 +56,32 @@ jobs:
|
|||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
MERGED="$(gh pr view "$PR" --json merged -q .merged)"
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "PR #$PR não mergeada — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -70,14 +70,14 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
|
|||
|
||||
## CI por alvo (GitHub Actions, OPS71)
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | ----------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
|
||||
|
||||
|
|
@ -87,13 +87,20 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
|||
|
||||
### Secrets (humano, uma vez — repo GitHub)
|
||||
|
||||
| Secret | Uso |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
| Secret | Uso |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
|
||||
| `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
|
||||
Local: `GITHUB_TOKEN` (PAT com escopo `repo`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. 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 GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50).
|
||||
|
||||
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
|
||||
|
||||
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
|
||||
|
||||
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). Fallback local (sem GitHub): `PR_BODY='Closes #N' node scripts/forgejo-issue-transition.mjs --pr <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
|
||||
|
||||
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
|
||||
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: HEAD guard (`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público) → flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
|
||||
|
||||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-20-ops71-flip.md
Normal file
1
docs/changelog/2026-08-20-ops71-flip.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
97
docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md
Normal file
97
docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Impl: OPS71-FLIP — Flip pós-merge falhou/silencioso no fluxo GitHub — validar e endurecer issue-done-on-main-merge
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: #119
|
||||
Intenção: body da Issue (sem plano linkado — o body é a spec)
|
||||
Appetite restante: herdado (chore P2, sem appetite declarado — escopo = as 4 ações sugeridas + fix de raiz)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** todo PR mergeado em `main` com `Closes #N` deve flipar a Issue no Forgejo para `done`+`in-prod` + comentário + **close** — de forma **não silenciosa** (falha visível) e com **caminho de recuperação manual** quando o evento do GitHub não criar run.
|
||||
- **O que NÃO negociar:** tracker continua no Forgejo (flips via `FORGEJO_API_TOKEN`); fork guard (RCE) preservado; flips nunca mentem (OPS61: job vermelho se qualquer flip falhar); CI green é o único gate de merge.
|
||||
- **O que reavaliar:** a hipótese da Issue ("tipos de evento / entrega do GitHub") estava certa só pela metade — a causa real é a **anti-recursão do GITHUB_TOKEN** (merge atribuído a `github-actions[bot]` não cria runs); a hipótese "410 é transitório, só pin/retry" é insuficiente — o 410 quebrou o run do #742 **depois** do evento ter criado o run; os dois problemas são independentes.
|
||||
|
||||
## Investigação (root cause — evidência ao vivo, GitHub API pública)
|
||||
|
||||
Comparação dos dois merges (timeline `issues/{n}/timeline` + `actions/runs`):
|
||||
|
||||
| | PR #742 (Fase 1) | PR #746 (Fase 2) |
|
||||
| ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `auto_rebase_enabled` | `github-actions[bot]` 21:20:38Z | `github-actions[bot]` 00:02:32Z |
|
||||
| `merged` / `closed` | **`fsolla`** 22:17:20Z | **`github-actions[bot]`** 00:20:11Z |
|
||||
| Runs criados no `closed` | 32308114529 (falhou 410) + 32308114530 (skipped), 3s após o merge | **0 runs** (nenhum evento, nenhum head — janela 00:00–00:45 verificada) |
|
||||
|
||||
**Causa raiz:** o auto-merge nativo armado via `enablePullRequestAutoMerge` (GraphQL) com o **GITHUB_TOKEN** do workflow executa o merge como `github-actions[bot]`. O GitHub **documenta** ("Automatic token authentication", anti-recursão): eventos acionados por ações feitas com o `GITHUB_TOKEN` **não criam workflow runs** (exceção: `workflow_dispatch`, `repository_dispatch`). O `closed` fica no timeline do PR, mas o Actions não gera run nenhum — nem `pull_request`, nem `push` (0 runs na janela do merge). Toda auto-merge por bot vai pular os dois flips pós-merge **em silêncio** (run nem é criado — nunca vai aparecer vermelho).
|
||||
|
||||
O 410 é falha **separada**: o run do #742 foi criado normalmente (merge por humano) e o `GET /issues/97/labels` respondeu 410 Gone (nginx) — transitório, endpoint ok às 00:3x. O retry do OPS67 cobre fetch-rejection + 5xx-no-GET; 410 (4xx) nunca retenta; o corpo nginx HTML entra no erro só como slice cru de 400 chars (imprestável para log).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M["PR mergea em main"] -->|"auto-merge armado com PAT real (AUTOMERGE_PAT)"| E["closed event → runs criados<br/>(merge atribuído ao dono do PAT)"]
|
||||
M -. "legado: bot merge (GITHUB_TOKEN)" .-> X["anti-recursão: 0 runs<br/>(causa raiz #746)"]
|
||||
E --> F["issue-done / plan-issue-ready flipam no Forgejo"]
|
||||
F --> C["labels done+in-prod + comentário + close<br/>(forgejo-issue-transition.mjs)"]
|
||||
H["humano: dispatch manual (recovery)"] --> G["gh pr view → PR_BODY → mesmo script"]
|
||||
C -->|"410/5xx transitório"| R["retry 410 no forgejo-api<br/>(proxy respondeu — origem intocada)"]
|
||||
```
|
||||
|
||||
**Opções consideradas (fix de raiz):** A | B | C
|
||||
**Recomendação:** **A + recuperação manual + close + retry 410** — a trinca atende os 4 pontos da Issue e conserta a causa que a torna _silenciosa_.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — manter arming com GITHUB_TOKEN e só adicionar dispatch manual:** o flip continua quebrado em todo merge de bot (o "silencioso" do título); o humano vira o flip de todo PR. Rejeitada porque a Issue é exatamente sobre o silêncio.
|
||||
- **C — poller (schedule) que varre PRs mergeados:** disparo a cada N minutos com API lendo PRs fechados = latência, races, e um "verificador" novo que o OPS71 eliminou de propósito; PAT + evento nativo é mais simples e imediato.
|
||||
|
||||
**Opções consideradas (recuperação manual):** A | B
|
||||
**Recomendação:** **A — `workflow_dispatch` com input `pr`** (o script já aceita `PR_BODY`; o step de dispatch busca body+merged via `gh` e repassa). B (rodar o script local com `PR_BODY`) é o fallback final do runbook, sem YAML novo.
|
||||
**Rejeitadas:** C — GitHub App só para isso (peso de registrar app; o repo já usa PATs).
|
||||
|
||||
**Opções consideradas (410):** A | B
|
||||
**Recomendação:** **A — 410 entra no retry do `forgejo-api.mjs` para QUALQUER método** (não só GET): 410 vem do proxy (nginx/Cloudflare) — a origem **nunca viu** o request, então retentar escrita não duplica efeito colateral (diferente de 5xx, onde a origem pode ter aplicado o write — esse continua fail-closed). Corpo não-JSON vira marker compacto no erro.
|
||||
**Rejeitadas:** B — 410 só no GET (o flip faz `removeLabels` GET → `addLabels` POST → `addComment` POST; um 410 no POST continuaria quebrando o flip apesar da classificação "transitório conhecido").
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/agent-pr-ready-automerge.yml`**: passa `AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}` ao step; comentário do porquê (anti-recursão).
|
||||
- **`scripts/github-pr-automerge.mjs`**: **fail-closed** — se `AUTOMERGE_PAT` ausente, `exit 1` com mensagem nomeando o secret e o motivo (nunca fallback silencioso para GITHUB_TOKEN); `enableAutoMerge` passa a usar `createApi({ token: AUTOMERGE_PAT })` (o `markPullRequestReady` continua com o token do run — PATCH draft:false não precisa gerar evento: nada consome `ready_for_review` além do próprio workflow, e o CI corre no `synchronize` do push do agente).
|
||||
- **`.github/workflows/issue-done-on-main-merge.yml`** + **`plan-issue-ready-on-main-merge.yml`**: `on: workflow_dispatch` com input `pr` (required); job `if:` = evento (`closed` + merged + main + same-repo) **ou** dispatch com input; permission `pull-requests: read` (gh); step de dispatch: `gh pr view` → valida `merged == true` + `baseRefName == main` + `headRepository.nameWithOwner == github.repository` (mesmo contrato do evento — um dispatch num PR de fork/aberto é skip) → `PR_BODY` via `GITHUB_OUTPUT`; step do node usa `steps.pr.outputs.* || github.event.pull_request.*` (um caminho só, sem override vazio). O script já no-ops sem `Closes`/`Related`.
|
||||
- **`scripts/forgejo-issue-transition.mjs`**: no loop do flip, após labels+comentário → **`api.closeIssue(number)`** (era-Forgejo o close era nativo do merge do PR; era-GitHub o PR não toca o tracker — sem o PATCH toda Issue mergeada fica `OPEN` com `done`+`in-prod`). Falha de close = `flipFailed` (OPS61: vermelho, nunca mentir). Comentário ganha menção ao close.
|
||||
- **`scripts/lib/forgejo-api.mjs`**: `RETRYABLE_BEFORE_ORIGIN = new Set([410])` retentado em qualquer método (comentário: proxy respondeu — origem intocada, sem risco de efeito duplicado; 5xx segue GET-only); erro `!response.ok` com corpo HTML → marker compacto `corpo não-JSON (proxy/nginx — HTTP <status>): <120 chars>` em vez do slice cru.
|
||||
- **`scripts/lib/github-api.mjs`**: **intocado** (o 410 observado é do proxy do Forgejo; github-api não recebe o mesmo tratamento por escopo — política documentada nos headers de ambos).
|
||||
- **`docs/AGENT-OPS.md`**: tabela de workflows (rows dos dois flips ganham "dispatch manual de recuperação"), tabela de secrets (`AUTOMERGE_PAT`), parágrafo do flip com o porquê bot-merge não cria run + runbook de recuperação (dispatch com número do PR; fallback local `PR_BODY`).
|
||||
- **`AGENTS.md`**: frase curta no parágrafo do OPS71 (auto-merge armado com `AUTOMERGE_PAT` — merge por bot não cria runs).
|
||||
- **Migration:** sem migration. **Access/Consent:** n/a. **UI:** n/a.
|
||||
- **Changelog:** `docs/changelog/2026-08-20-ops71-flip.md` + `pnpm changelog:build`.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Lib + script do flip** — `forgejo-api.mjs` (410 + closeIssue + marker HTML), `forgejo-issue-transition.mjs` (close). Unit specs novas em `tests/unit/forgejoApi.unit.spec.ts` (410 GET retry, 410 write retry, 410 esgotado → marker HTML, closeIssue PATCH pin). Verificação: `pnpm test` (spec), `pnpm exec tsc --noEmit`, lint/format.
|
||||
2. **Auto-merge com PAT** — `github-pr-automerge.mjs` fail-closed + workflow env. Verificação: tsc/lint; comportamento real no próprio PR desta entrega (CI verde → auto-merge armado com o PAT).
|
||||
3. **Workflows de recuperação** — `workflow_dispatch` nos dois flips. Verificação: YAML válido (CI do PR), gate do humano.
|
||||
4. **Gates + docs + changelog** — `pnpm gate:fast`; `pnpm changelog:build`; AGENT-OPS/AGENTS.
|
||||
5. **Pós-merge (validação ao vivo, humano):** criar o secret `AUTOMERGE_PAT` **antes** do merge deste PR (senão o auto-merge do próprio PR falha fail-closed); conferir o flip da #119 (run criado pelo `closed` do merge — agora atribuído ao humano) e, como recovery proof, disparar o dispatch manual do `issue-done` uma vez.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Retry 410 no `github-api.mjs` (simetria): sem evidência de 410 no GitHub API; política documentada, gatilho = ocorrência real.
|
||||
- GitHub App dedicado para o merge (B do fix de raiz): rejeitado no gate — PAT fine-grained basta.
|
||||
- `agent-promote-related-on-merge.mjs` (`setLabels` do `agent-forgejo.mjs`): intocado — só o workflow dele ganha o dispatch.
|
||||
- Poller/`schedule` de verificação de merges (C): rejeitado — o evento nativo + dispatch cobre.
|
||||
- Drift do `ci-pr.yml` / branch protection: fora de escopo.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Secret `AUTOMERGE_PAT` ainda não existe no merge deste PR** → o fail-closed derruba o auto-merge de todo PR até o humano criar. Mitigação: passo 5 da fase pós-merge declara criar o secret **primeiro**; sem o secret, merges manuais continuam funcionando (o bot não arma, o humano mergea — `closed` por humano gera runs, comportamento #742).
|
||||
- **Fine-grained PAT sem permissão de merge** → `enablePullRequestAutoMerge` GraphQL falha → job vermelho (visível, não silencioso). Mitigação: runbook documenta escopos (`pull-requests: write` + `contents: write` no repo fsolla/teqo).
|
||||
- **Dispatch manual num PR não mergeado / fork** → skip (validação `merged` + `baseRefName` + `headRepository` no step), mesma semântica do evento.
|
||||
- **`gh` no runner hosted**: pré-instalado no `ubuntu-latest`; `GITHUB_TOKEN` injetado automaticamente; sem dependência nova.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (flip sempre acontece; falha é visível; recovery existe)
|
||||
- [x] Invariantes AGENTS/engineering-standards (fork guard nos dois workflows, OPS61 exit-1, zero-dep plain Node, DRY <3 call sites)
|
||||
- [x] Testes de domínio previstos (unit: retry 410 GET/write, marker HTML, closeIssue, fail-closed PAT)
|
||||
- [x] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; abordagem no appetite; rabbit holes nomeados; depth check ok — reusa createApi/PR_BODY; intenção preservada)
|
||||
|
|
@ -71,9 +71,12 @@ for (const number of numbers) {
|
|||
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
|
||||
)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
|
||||
// OPS71-FLIP: o PR vive no GitHub e não fecha a Issue no Forgejo como o
|
||||
// merge nativo fazia na era Forgejo — o close é explícito aqui.
|
||||
await api.closeIssue(number)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod + fechada`)
|
||||
} catch (error) {
|
||||
flipFailed = true
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — GitHub PR safety net: mark `cursor/*` drafts
|
||||
* ready and arm GitHub's native auto-merge (rebase). Runs in
|
||||
* .github/workflows/agent-pr-ready-automerge.yml with the built-in
|
||||
* GITHUB_TOKEN (permissions: contents+pull-requests write).
|
||||
* .github/workflows/agent-pr-ready-automerge.yml. The auto-merge arming uses
|
||||
* the AUTOMERGE_PAT (real user PAT) — never the built-in GITHUB_TOKEN, whose
|
||||
* actor (github-actions[bot]) would merge without creating workflow runs for
|
||||
* the `closed` event (OPS71-FLIP; anti-recursion — silent broken flips).
|
||||
* Mark-ready keeps the run token (nothing consumes its event).
|
||||
*
|
||||
* Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO
|
||||
* poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges
|
||||
|
|
@ -20,7 +23,7 @@
|
|||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { decideAutomergeAction } from './lib/github-pr-flow.mjs'
|
||||
import { automergeArmingToken, decideAutomergeAction } from './lib/github-pr-flow.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
|
|
@ -44,6 +47,20 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|||
}
|
||||
|
||||
const api = createApi({})
|
||||
// OPS71-FLIP: the native auto-merge MUST be armed with a real user PAT, not
|
||||
// the built-in GITHUB_TOKEN. A merge performed through the GITHUB_TOKEN actor
|
||||
// (github-actions[bot]) never creates workflow runs for its events (GitHub
|
||||
// anti-recursion — the `pull_request` `closed` is recorded in the timeline
|
||||
// but no run is created), so the post-merge flips would silently never run
|
||||
// (live finding: PR #746). With a PAT the merge is attributed to its owner (a
|
||||
// real user) and the `closed` event creates runs again (PR #742 behavior).
|
||||
// Fail-closed: no AUTOMERGE_PAT → exit 1 — never fall back to GITHUB_TOKEN.
|
||||
const arming = automergeArmingToken(process.env)
|
||||
if (!arming.ok) {
|
||||
console.error(`[github-pr-automerge] ${arming.reason}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const automergeApi = createApi({ token: arming.token })
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const verdict = decideAutomergeAction(pr)
|
||||
|
|
@ -59,7 +76,7 @@ try {
|
|||
}
|
||||
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
|
||||
await api.enableAutoMerge(pr.nodeId)
|
||||
await automergeApi.enableAutoMerge(pr.nodeId)
|
||||
console.log(
|
||||
`[github-pr-automerge] PR #${prNumber}: auto-merge armado — o GitHub mergea quando o required check "CI (PR) / checks" fica verde`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
* that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY
|
||||
* method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the
|
||||
* safety net's long `waitForChecks` poll survives, while write endpoints keep
|
||||
* failing closed instead of risking duplicated side effects. 4xx never
|
||||
* retries. Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
|
||||
* failing closed instead of risking duplicated side effects. 410 Gone
|
||||
* (OPS71-FLIP): served by the reverse proxy (nginx/Cloudflare) BEFORE the
|
||||
* request reaches the app — the origin never saw it, so retrying is safe on
|
||||
* ANY method (unlike a 5xx, where the origin may have applied the write).
|
||||
* 4xx never retries (410 is the one documented proxy-level exception).
|
||||
* Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
|
||||
*
|
||||
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
|
||||
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
|
||||
|
|
@ -24,6 +28,10 @@ const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
|
|||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||||
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
|
||||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||||
// 410 Gone from the reverse proxy (OPS71-FLIP live finding: nginx answered
|
||||
// `GET /repos/fsolla/teqo/issues/97/labels` with 410, transient). The origin
|
||||
// never received the request — retrying is side-effect-free on ANY method.
|
||||
const RETRYABLE_BEFORE_ORIGIN = new Set([410])
|
||||
|
||||
/**
|
||||
* @typedef {object} ForgejoApiOptions
|
||||
|
|
@ -124,6 +132,11 @@ export const createApi = ({
|
|||
await retryAfter(attempt, `HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
if (!isLastAttempt && RETRYABLE_BEFORE_ORIGIN.has(response.status)) {
|
||||
await response.body?.cancel()
|
||||
await retryAfter(attempt, `HTTP ${response.status} (proxy — origem intocada)`)
|
||||
continue
|
||||
}
|
||||
let text
|
||||
try {
|
||||
text = await response.text()
|
||||
|
|
@ -133,7 +146,14 @@ export const createApi = ({
|
|||
continue
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
const isProxyHtml = /^\s*</.test(text)
|
||||
const snippet = isProxyHtml
|
||||
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120)}`
|
||||
: text.slice(0, 400)
|
||||
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
|
||||
}
|
||||
if (!text) return null
|
||||
try {
|
||||
|
|
@ -214,6 +234,18 @@ export const createApi = ({
|
|||
body: { body },
|
||||
}),
|
||||
|
||||
/**
|
||||
* PATCH state=closed — closes the issue on the tracker. Forgejo-era: the
|
||||
* merge of the PR closed the issue natively. GitHub-era (OPS71): the PR
|
||||
* lives on GitHub and never touches the Forgejo tracker — the flip must
|
||||
* close explicitly, else every merged issue stays OPEN with done+in-prod.
|
||||
*/
|
||||
closeIssue: (number) =>
|
||||
request(`/repos/${owner}/${name}/issues/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: { state: 'closed' },
|
||||
}),
|
||||
|
||||
listIssueComments: async (number) => {
|
||||
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
|
||||
return (Array.isArray(comments) ? comments : []).map((comment) => ({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,27 @@ export const CURSOR_HEAD_PREFIX = 'cursor/'
|
|||
/** OPS57: only `cursor/*` drafts get force-marked ready — any other draft is the actor's veto. */
|
||||
export const isCursorHead = (ref) => typeof ref === 'string' && ref.startsWith(CURSOR_HEAD_PREFIX)
|
||||
|
||||
/**
|
||||
* OPS71-FLIP: the auto-merge MUST be armed with a real user PAT. Arming with
|
||||
* the built-in GITHUB_TOKEN makes the merge happen as `github-actions[bot]`,
|
||||
* and GitHub's anti-recursion never creates workflow runs for events
|
||||
* triggered by GITHUB_TOKEN actions — the post-merge `closed` flips would
|
||||
* silently never run (live finding PR #746). Fail-closed: no token → ok:false;
|
||||
* the CLI exits 1 instead of falling back to the run token.
|
||||
*
|
||||
* @param {{ AUTOMERGE_PAT?: string }} [env]
|
||||
* @returns {{ ok: true, token: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
export const automergeArmingToken = (env = process.env) => {
|
||||
const token = env.AUTOMERGE_PAT
|
||||
if (typeof token === 'string' && token.length > 0) return { ok: true, token }
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'AUTOMERGE_PAT ausente — sem ele o merge aconteceria como github-actions[bot] e o evento closed não criaria runs (flips pós-merge silenciosos). Defina o secret AUTOMERGE_PAT no GitHub (PAT do fsolla, escopos pull-requests: write + contents: write no repo) — ver docs/AGENT-OPS.md.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} pr - normalized PR from github-api (or null)
|
||||
* @returns {{ action: 'skip' | 'mark-ready' | 'enable-auto-merge', reason: string }}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,28 @@ describe('forgejo-api', () => {
|
|||
expect(JSON.parse(calls[2].body as string)).toEqual({ labels: ['ready'] })
|
||||
})
|
||||
|
||||
it('closeIssue PATCHes state=closed (OPS71-FLIP: the GitHub PR merge no longer closes the Forgejo issue)', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
base: 'https://git.solla.dev/api/v1',
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.closeIssue(9)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
url: 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/9',
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state: 'closed' }),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('workflowDispatch posts inputs and ref', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
|
|
@ -871,5 +893,69 @@ describe('forgejo-api', () => {
|
|||
await expect(api.addComment(1, 'x')).rejects.toThrow(/503/)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a 410 on GET (proxy answered — origin untouched)', async () => {
|
||||
silentWarn()
|
||||
let calls = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
retries: 2,
|
||||
backoffMs: 0,
|
||||
jitter: false,
|
||||
sleepImpl: async () => {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
return calls === 1 ? ok('<html>410 Gone</html>', 410) : ok(issueOk)
|
||||
},
|
||||
})
|
||||
|
||||
const issues = await api.listIssues()
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(issues).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retries a 410 on write methods too (nginx answered before the origin — no duplicated side effect)', async () => {
|
||||
silentWarn()
|
||||
let calls = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
retries: 2,
|
||||
backoffMs: 0,
|
||||
jitter: false,
|
||||
sleepImpl: async () => {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
return calls === 1 ? ok('<html>410 Gone</html>', 410) : ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.addComment(1, 'x')
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('throws a compact proxy marker after exhausting 410 retries (nginx html not swallowed raw)', async () => {
|
||||
silentWarn()
|
||||
let calls = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
retries: 1,
|
||||
backoffMs: 0,
|
||||
jitter: false,
|
||||
sleepImpl: async () => {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
return ok('<html>\n<head><title>410 Gone</title></head>\n<body>gone</body></html>', 410)
|
||||
},
|
||||
})
|
||||
|
||||
const error = await api.addComment(1, 'x').catch((e: unknown) => e)
|
||||
|
||||
expect((error as Error).message).toMatch(/410/)
|
||||
expect((error as Error).message).toMatch(/corpo não-JSON \(proxy\/nginx/)
|
||||
expect((error as Error).message).toMatch(/410 Gone/)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
CURSOR_HEAD_PREFIX,
|
||||
automergeArmingToken,
|
||||
decideAutomergeAction,
|
||||
isCursorHead,
|
||||
} from '../../scripts/lib/github-pr-flow.mjs'
|
||||
|
|
@ -75,4 +76,21 @@ describe('github-pr-flow automerge decision (OPS71)', () => {
|
|||
]
|
||||
expect(actions).toEqual(['enable-auto-merge', 'mark-ready'])
|
||||
})
|
||||
|
||||
it('automergeArmingToken accepts a non-empty AUTOMERGE_PAT (OPS71-FLIP)', () => {
|
||||
expect(automergeArmingToken({ AUTOMERGE_PAT: 'ghp_abc' })).toEqual({
|
||||
ok: true,
|
||||
token: 'ghp_abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('automergeArmingToken fails closed without the PAT — never falls back to the run token', () => {
|
||||
const verdict = automergeArmingToken({})
|
||||
expect(verdict.ok).toBe(false)
|
||||
expect(verdict.ok === false && verdict.reason).toContain('AUTOMERGE_PAT')
|
||||
})
|
||||
|
||||
it('automergeArmingToken treats an empty-string PAT as missing', () => {
|
||||
expect(automergeArmingToken({ AUTOMERGE_PAT: '' }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue