diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml index d31d0e1c..82470666 100644 --- a/.github/workflows/agent-pr-ready-automerge.yml +++ b/.github/workflows/agent-pr-ready-automerge.yml @@ -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 }}" diff --git a/.github/workflows/issue-done-on-main-merge.yml b/.github/workflows/issue-done-on-main-merge.yml index 578501ff..7cf7e007 100644 --- a/.github/workflows/issue-done-on-main-merge.yml +++ b/.github/workflows/issue-done-on-main-merge.yml @@ -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<> "$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 }}" diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.github/workflows/plan-issue-ready-on-main-merge.yml index 2213cf1a..41273686 100644 --- a/.github/workflows/plan-issue-ready-on-main-merge.yml +++ b/.github/workflows/plan-issue-ready-on-main-merge.yml @@ -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<> "$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 }}" diff --git a/AGENTS.md b/AGENTS.md index dad43d36..aa98a661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a secondary remote and its git repo freezes at the cutover point — tracker intacto por API). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a secondary remote and its git repo freezes at the cutover point — tracker intacto por API). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 7aa5748d..b02e8217 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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 ` **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 ` **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 ` 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 ` 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). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 356deddd..38e319ae 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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. diff --git a/docs/changelog/2026-08-20-ops71-flip.md b/docs/changelog/2026-08-20-ops71-flip.md new file mode 100644 index 00000000..f38173c9 --- /dev/null +++ b/docs/changelog/2026-08-20-ops71-flip.md @@ -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). diff --git a/docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md b/docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md new file mode 100644 index 00000000..b4a628fc --- /dev/null +++ b/docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md @@ -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
(merge atribuído ao dono do PAT)"] + M -. "legado: bot merge (GITHUB_TOKEN)" .-> X["anti-recursão: 0 runs
(causa raiz #746)"] + E --> F["issue-done / plan-issue-ready flipam no Forgejo"] + F --> C["labels done+in-prod + comentário + close
(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
(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 ): <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) diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/forgejo-issue-transition.mjs index 5092283f..28822b36 100644 --- a/scripts/forgejo-issue-transition.mjs +++ b/scripts/forgejo-issue-transition.mjs @@ -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( diff --git a/scripts/github-pr-automerge.mjs b/scripts/github-pr-automerge.mjs index f5c376b7..a7828184 100644 --- a/scripts/github-pr-automerge.mjs +++ b/scripts/github-pr-automerge.mjs @@ -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`, ) diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs index 7381ca49..0fd60762 100644 --- a/scripts/lib/forgejo-api.mjs +++ b/scripts/lib/forgejo-api.mjs @@ -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* + 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) => ({ diff --git a/scripts/lib/github-pr-flow.mjs b/scripts/lib/github-pr-flow.mjs index d6f91f61..17823876 100644 --- a/scripts/lib/github-pr-flow.mjs +++ b/scripts/lib/github-pr-flow.mjs @@ -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 }} diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts index 57615c82..18cb5de2 100644 --- a/tests/unit/forgejoApi.unit.spec.ts +++ b/tests/unit/forgejoApi.unit.spec.ts @@ -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('410 Gone', 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('410 Gone', 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('\n410 Gone\ngone', 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) + }) }) }) diff --git a/tests/unit/githubPrFlow.unit.spec.ts b/tests/unit/githubPrFlow.unit.spec.ts index 8e0abf35..66b889e4 100644 --- a/tests/unit/githubPrFlow.unit.spec.ts +++ b/tests/unit/githubPrFlow.unit.spec.ts @@ -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) + }) })