feat(OPS71): CI/PR/deploy no GitHub Actions — tracker de Issues permanece no Forgejo (Closes #97)
This commit is contained in:
parent
693d2e481b
commit
24a867dfc3
27 changed files with 1850 additions and 211 deletions
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge imediato
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge nativo do GitHub
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent PR workflow (Teqo)
|
||||
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + auto-merge.
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + GitHub's native auto-merge (OPS71). The ISSUE TRACKER stays on the Forgejo — GitHub is only the Actions host.
|
||||
|
||||
## Create (Ready, base main)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD
|
||||
pnpm push -u origin HEAD # origin = GitHub (cutover OPS71)
|
||||
```
|
||||
|
||||
Depois, abra a PR via API/MCP do opencode (humano) ou `ManagePullRequest` (Cursor Cloud) — **Ready**, base **`main`**, title `<id> — <title>`, body:
|
||||
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
|
||||
|
||||
```
|
||||
## O que é
|
||||
|
|
@ -25,11 +25,12 @@ Closes #<N>
|
|||
- **Never** `--draft`. PRs must be **Ready for review** on creation.
|
||||
- Base is always **`main`**.
|
||||
- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`).
|
||||
- PR via CLI (não há `gh` — OPS50): `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<t>" --body-file <file>` — Ready por construção (não existe flag draft).
|
||||
|
||||
### Cursor Cloud / `ManagePullRequest`
|
||||
|
||||
- **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**).
|
||||
- Não há auto-merge a armar por PR: o safety net (abaixo) espera o rollup `checks` e mergea sozinho.
|
||||
- Não há auto-merge a armar por PR: o safety net (abaixo) arma o auto-merge nativo.
|
||||
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
|
||||
|
||||
## Push (determinístico)
|
||||
|
|
@ -37,66 +38,51 @@ Closes #<N>
|
|||
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
|
||||
|
||||
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (e2e roda uma vez, no CI, após o push; local opcional: `pnpm test:e2e:affected`), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (e2e roda no CI de PR selected/afetado, e full no `verify` do deploy manual; local opcional: `pnpm test:e2e:affected`), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD # canonical
|
||||
pnpm push -u origin HEAD # canonical — origin é o GitHub (OPS71)
|
||||
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
|
||||
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
|
||||
```
|
||||
|
||||
## Auto-merge immediately
|
||||
|
||||
The safety net (`agent-pr-ready-automerge.yml`, abaixo) merges the PR when the required checks pass — there is nothing to arm per PR. Do not wait for human approval on normal deliveries.
|
||||
The safety net (`agent-pr-ready-automerge.yml`, abaixo) arms GitHub's **native auto-merge** (rebase) — the server merges the PR when the required check passes. There is nothing to arm per PR. Do not wait for human approval on normal deliveries.
|
||||
|
||||
## Required checks
|
||||
|
||||
Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
|
||||
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
|
||||
|
||||
- `CI (PR) / checks` — the ci-pr cascade rollup (the only context that covers
|
||||
every ci-pr job; on the server the rule matches it as a glob,
|
||||
`CI (PR) / checks*`)
|
||||
- `CI (PR) / checks` — the ci-pr single-job check-run (OPS62: one sequential job posts one honest verdict; no matrix, no shards, no rollup to lie)
|
||||
|
||||
OPS64: the rollup is structurally honest since OPS62 — ci-pr.yml is a SINGLE
|
||||
sequential job (`checks`), so its own status IS the cascade verdict (no
|
||||
matrix, no shards, no parent aggregation to lie). The historical bug (Forgejo
|
||||
9.0.3 reporting the LAST matrix shard's result as the parent's in `needs`,
|
||||
posting `checks` SUCCESS with an e2e shard red — runs 604/607/610) is gone by
|
||||
construction; the `needs.<job>.results` array of GitHub does not exist in this
|
||||
runner, so any future matrix must NOT aggregate via `needs`. The safety-net
|
||||
CLI pins the defense: it never trusts the rollup alone (scans individual
|
||||
statuses; run-607 scenario unit-tested in `forgejoApi.unit.spec.ts`).
|
||||
OPS64 history: the Forgejo runner could report the LAST matrix shard's result as the parent's (runs 604/607/610) — that bug is gone by construction: ci-pr.yml is a SINGLE sequential job, and GitHub's native auto-merge only merges with every required check green (server-side guarantee). The safety-net CLI has no poll path to port.
|
||||
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
|
||||
|
||||
**There is no deploy from PR CI.** The `ci.yml` verifier on `main` runs the full suite in its fixed 30-minute window and, on a production change, the `deploy` job publishes to the homeserver (`jorgesolla1313.com.br`, self-hosted) — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
|
||||
When watching CI, use the PR page (or the API) — the safety net is what waits on checks; `gh` does not exist in this repo (OPS50).
|
||||
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
|
||||
|
||||
Use the rollup context `CI (PR) / checks` — without it, you are not covering the cascade.
|
||||
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
|
||||
|
||||
## Promote
|
||||
|
||||
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies the full suite and, on a production change, deploys to the homeserver (OPS53/OPS65; runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
## Safety net (workflow)
|
||||
|
||||
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
|
||||
O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
|
||||
draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
|
||||
equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
|
||||
`CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
|
||||
quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
|
||||
postam status; o próprio status do job do safety net é `pending` durante o run
|
||||
e **nunca** entra no veredito). O CLI nunca confia no rollup sozinho: varre
|
||||
todos os statuses individuais e falha em qualquer `failure`/`error` — um shard
|
||||
de e2e vermelho bloqueia o merge mesmo com o rollup verde (OPS64; run 607).
|
||||
A regra de branch protection no servidor
|
||||
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito
|
||||
— inclusive merges manuais via API: foi um merge manual que colocou a PR #52
|
||||
em main com o CI vermelho (o CLI nunca mergeou sozinho antes do OPS61; todo
|
||||
merge Forgejo era manual).
|
||||
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep,
|
||||
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm
|
||||
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR
|
||||
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde
|
||||
antes de mergear; o próprio check-run do safety net fica `pending` durante o
|
||||
run e não interfere. A regra de branch protection no servidor
|
||||
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API
|
||||
manual — enquanto o required check não estiver satisfeito (foi um merge
|
||||
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo).
|
||||
Cobre o caso em que o
|
||||
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
|
||||
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Conduz **uma** Issue já claimada do plano de implementação ao merge em `main`
|
|||
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`)
|
||||
- [ ] 4. Executar o impl plan (schema/server → UI → gates)
|
||||
- [ ] 5. /simplify → fixes; capture-review-debts (modo autônomo)
|
||||
- [ ] 6. PR Ready `--base main` + auto-merge + checks --required
|
||||
- [ ] 6. PR Ready `--base main` (GitHub) + auto-merge nativo — `pnpm push -u origin HEAD` → `node scripts/github-pr.mjs` (ou `ManagePullRequest` `draft: false` no Cloud)
|
||||
```
|
||||
|
||||
## Passo 0 — Prep
|
||||
|
|
@ -84,8 +84,9 @@ O spawn do pool já fixou o modelo (`model:` da Issue — ver `model-selection`)
|
|||
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
|
||||
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
|
||||
**não** polua a fila.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
|
||||
Passo 0.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub**
|
||||
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71);
|
||||
Prep Cloud no Passo 0.
|
||||
|
||||
## Resumo final
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ Para cada lote com destino **registrar** ou **absorver**:
|
|||
|
||||
Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issue`, só se o usuário (ou o pipeline autônomo) seguir.
|
||||
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR no GitHub `--base main` com `Closes #N` → auto-merge nativo → CI).
|
||||
|
||||
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,10 +34,15 @@ entrega.
|
|||
e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
|
||||
(insert-only: entradas históricas nunca mudam; o diff será só a entrada
|
||||
nova + o agregado). Rode `pnpm changelog:check` para confirmar.
|
||||
3. **`pnpm push -u origin HEAD`**
|
||||
4. PR via API/MCP (ou `ManagePullRequest` no Cloud) — **Ready** (nunca draft), base `main`, com `Closes #<N>`
|
||||
5. O safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase — nada a armar
|
||||
6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
|
||||
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de
|
||||
Issues continua no Forgejo por API).
|
||||
4. PR no **GitHub** via `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<id> — <título>" --body-file <arquivo>` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #<N>` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`.
|
||||
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do
|
||||
GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando
|
||||
o required check `CI (PR) / checks` fica verde; nada a armar.
|
||||
6. No merge, o workflow `issue-done-on-main-merge.yml` flipa `done`/`in-prod`
|
||||
**no Forgejo** (lê o body do PR via API do GitHub, escreve no tracker por
|
||||
`FORGEJO_API_TOKEN`). Comente na Issue o desfecho em uma linha.
|
||||
|
||||
## Deltas por ator
|
||||
|
||||
|
|
|
|||
52
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
52
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Safety net (OPS71 — GitHub): every same-repo PR to main must be Ready +
|
||||
# auto-merge armed. Covers Cloud ManagePullRequest defaulting to draft,
|
||||
# agents that forget to arm the merge, and human/worktree PRs (the OPS52
|
||||
# incident was a human PR whose merge failed silently). Draft PRs from
|
||||
# non-`cursor/*` branches are never touched — the draft flag is the actor's
|
||||
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
|
||||
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
|
||||
#
|
||||
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI
|
||||
# (forgejo-pr-automerge.mjs): `enablePullRequestAutoMerge` (GraphQL, REBASE —
|
||||
# the repo's canonical merge style) is server-side — GitHub only merges when
|
||||
# every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is
|
||||
# green and the PR is mergeable. The OPS64 "rollup can lie" defense is
|
||||
# structural here: a single sequential job posts one honest check-run and the
|
||||
# server enforces it — nothing to poll, nothing to trust.
|
||||
#
|
||||
# Permissions: contents+pull-requests write — enough for PATCH draft:false
|
||||
# and the GraphQL auto-merge mutation with the built-in GITHUB_TOKEN.
|
||||
name: PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
|
||||
# PR, actions/checkout checks out the fork's head, so a same-repo guard
|
||||
# inside the CLI would already be running the fork's own script — RCE
|
||||
# with the Actions token. The draft policy lives in the CLI (skips
|
||||
# non-`cursor/*` drafts) because there the PR state is re-read from the
|
||||
# API, not from webhook payload fields.
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Mark Ready and arm auto-merge
|
||||
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
53
.github/workflows/archive-cursor-agent.yml
vendored
Normal file
53
.github/workflows/archive-cursor-agent.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Operator helper: cancel+archive a Cursor Cloud agent (CURSOR_API_KEY secret).
|
||||
name: Archive Cursor agent (ops)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent_id:
|
||||
description: Cursor agent id (bc-…)
|
||||
required: true
|
||||
type: string
|
||||
run_id:
|
||||
description: Cursor run id (run-…) optional cancel first
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
archive:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Cancel run (if any) + archive agent
|
||||
env:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
AGENT_ID: ${{ inputs.agent_id }}
|
||||
RUN_ID: ${{ inputs.run_id }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import {
|
||||
archiveCursorAgent,
|
||||
cancelCursorRun,
|
||||
} from './scripts/lib/agent-pool-cursor.mjs'
|
||||
|
||||
const agentId = process.env.AGENT_ID
|
||||
const runId = process.env.RUN_ID?.trim()
|
||||
if (!process.env.CURSOR_API_KEY) {
|
||||
console.error('CURSOR_API_KEY missing')
|
||||
process.exit(1)
|
||||
}
|
||||
if (runId) {
|
||||
try {
|
||||
await cancelCursorRun(agentId, runId)
|
||||
console.log('cancelled', runId)
|
||||
} catch (error) {
|
||||
console.warn('cancel failed (continuing):', error.message)
|
||||
}
|
||||
}
|
||||
await archiveCursorAgent(agentId)
|
||||
console.log('archived', agentId)
|
||||
NODE
|
||||
223
.github/workflows/ci-pr.yml
vendored
Normal file
223
.github/workflows/ci-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job
|
||||
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot.
|
||||
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job
|
||||
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight
|
||||
# run when the PR gets a new push (the Forgejo era had no cancel API; here it
|
||||
# is free and matches the OPS62 "one slot per PR" spirit).
|
||||
#
|
||||
# The job id `checks` preserves the `CI (PR) / checks` check-run that the
|
||||
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
|
||||
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
|
||||
# auto-merge is server-side: it only merges with every required check green,
|
||||
# so the OPS64 "rollup can lie" defense is structural here (single job posts
|
||||
# one honest check-run; no matrix, no shards).
|
||||
#
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
|
||||
# as a step; its outputs drive `if:` on the heavy steps (same modes as the
|
||||
# Forgejo cascade: none/changed/full). e2e policy: selected (affected) specs
|
||||
# on PRs — OPS72 tunes the local-affected flow afterwards.
|
||||
#
|
||||
# Schema migrations are NOT serialized across PRs (migration-lock removed
|
||||
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
|
||||
# PR, incl. campaignMigrationReconciliation.
|
||||
#
|
||||
# Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and
|
||||
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
|
||||
# as the RCE guard on the post-merge workflows.
|
||||
#
|
||||
# Postgres services live on the job network and are reached by NAME
|
||||
# (postgres-int / postgres-build on 5432) — no host publish, so concurrent
|
||||
# runs never collide on host ports. Two isolated services keep e2e from
|
||||
# inheriting int fixture residue.
|
||||
name: CI (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
# Services are reached by NAME on the job network (no host publish):
|
||||
# concurrent runs never collide on host ports, which the single
|
||||
# long-lived job would hold for the whole run. Two isolated services —
|
||||
# int and build+e2e — keep e2e from inheriting int fixture residue.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Classify PR blast radius
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
result=$(node scripts/ci-scope.mjs)
|
||||
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
|
||||
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Content guards are PR-scoped and cheap — run them before the suite so
|
||||
# a guard failure fails fast instead of after 20 minutes of tests.
|
||||
- name: Changelog is append-only
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: node scripts/check-changelog-append-only.mjs
|
||||
- name: Aggregate is up to date with docs/changelog
|
||||
run: node scripts/build-changelog.mjs --check
|
||||
- name: No conflict markers in markdown diffs
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: node scripts/check-docs-conflict-markers.mjs
|
||||
- name: Block Closes/Fixes/Resolves on plans-only PRs
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: node scripts/check-plans-only-pr-closes.mjs
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
if: steps.scope.outputs.code_mode != 'none'
|
||||
run: pnpm check:cycles
|
||||
|
||||
- name: Unit tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
- name: Unit tests (changed since PR base)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses). OPS50.
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Integration tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
env:
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients ("too many clients
|
||||
# already"). 4 forks stay well under. The unit step above stays
|
||||
# CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
- name: Integration tests (changed since PR base)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
env:
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build (against the local service database)
|
||||
if: steps.scope.outputs.build_mode != 'none'
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Selected mode needs the build too (the Playwright webServer serves
|
||||
# .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds).
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
if: steps.scope.outputs.e2e_mode == 'full'
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# 4 workers in one process replace the former 2 shards × 2 workers.
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
- name: E2E tests (manifest-selected specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}
|
||||
165
.github/workflows/deploy.yml
vendored
Normal file
165
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Manual production deploy (OPS71 — GitHub Actions). `workflow_dispatch`
|
||||
# only: there is NO automatic main verifier anymore (ci.yml eliminated in the
|
||||
# gate 2026-08-19) — publishing is a deliberate operator act, and this
|
||||
# workflow runs the FULL suite (no scope skips, e2e included) before the
|
||||
# deploy job touches the homeserver.
|
||||
#
|
||||
# TWO jobs, same workflow:
|
||||
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
|
||||
# left out — they gate PRs, not production).
|
||||
# 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 Cloudflare tunnel). The hosted job
|
||||
# never reaches the homeserver; the self-hosted job never counts
|
||||
# hosted minutes.
|
||||
#
|
||||
# The production build CANNOT leave the homeserver (migrate pre-build on the
|
||||
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
|
||||
# which is exactly why the hosted job only verifies and the self-hosted job
|
||||
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
|
||||
# deploy-homeserver.sh) and serialized by flock on the homeserver.
|
||||
#
|
||||
# The `deploy` job fetches the repo from github.com (public) — TEQO_REPO_URL
|
||||
# in deploy-homeserver.sh now defaults there; the script's HEAD guard only
|
||||
# deploys when the dispatched SHA is main's HEAD (a stale dispatch skips).
|
||||
|
||||
name: Deploy (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
# Services are reached by NAME on the job network (no host publish).
|
||||
# Two isolated services — int and build+e2e — keep e2e from inheriting
|
||||
# int fixture residue.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
run: pnpm check:cycles
|
||||
- name: Unit tests (full suite)
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses).
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Integration tests (full suite)
|
||||
env:
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients. 4 forks stay well
|
||||
# under. The unit step above stays CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Build (against the service database)
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Same dir the Playwright webServer serves (playwright.config.ts
|
||||
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
|
||||
deploy:
|
||||
name: deploy homeserver (teqo-1313)
|
||||
# OPS53 + OPS71. Runs on the GitHub self-hosted runner ON the homeserver
|
||||
# (labels self-hosted + homeserver): the script executes locally — no SSH
|
||||
# hop, no workstation involvement. Deliberately wired ONLY here (manual
|
||||
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on
|
||||
# the production machine, and only an explicit dispatch with the full
|
||||
# verify suite green can reach it.
|
||||
needs: [verify]
|
||||
if: needs.verify.result == 'success'
|
||||
runs-on: [self-hosted, homeserver]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy teqo-1313 on the homeserver
|
||||
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"
|
||||
34
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
34
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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).
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: 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
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
49
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
49
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
|
||||
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
|
||||
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS71:
|
||||
# the PR lives on GitHub, the issues on the Forgejo tracker — the job passes
|
||||
# the PR body via PR_BODY and scripts/agent-promote-related-on-merge.mjs flips
|
||||
# labels on the Forgejo API (FORGEJO_API_TOKEN; the built-in GITHUB_TOKEN
|
||||
# cannot touch Forgejo issues — OPS61).
|
||||
#
|
||||
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
|
||||
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
|
||||
# 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).
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: 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 #')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable
|
||||
# package-manager-cache: setup-node@v5 defaults to true and looks for
|
||||
# pnpm (packageManager in package.json) before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -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). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17; windowed since OPS65, 2026-08-18)**: `ci.yml` no longer fires on every push — it runs in a **fixed 30-minute window** (`schedule */30` + `workflow_dispatch`), where a cheap gate job (`gate`, host runner, no pnpm) reads the SHA deployed on the homeserver from `~/stack/docker-compose.yml` over SSH, diffs it against `main` HEAD and classifies "production change" using `.dockerignore` as the source of truth (docs/`.agents`/`.env*` changes skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` count as production — conservative). No production change → suite + deploy skipped, run stays green; production change → full suite + deploy in the same window; dispatch always runs the full pipeline. The `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; 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. Merge em `main` com mudança de produção verde == site atualizado em até ~1 h (janela de 30 min + suite + deploy). Runbook (rollback, falhas conhecidas): `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` 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`) are preserved as rollback until the OPS71 Fase 2 removal. 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.
|
||||
|
|
@ -158,7 +158,7 @@ The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTal
|
|||
|
||||
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use the homeserver prod DB for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt<slot>` / `teqo_wt<slot>_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names.
|
||||
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **O e2e não roda mais no gate local (OPS59):** ele roda **uma vez, no CI** (PR e main — gateia merge e deploy); local opcional para feedback antecipado: `pnpm test:e2e:affected` (gera `--no-deps` para runs filtrados — S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`).
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **O e2e não roda mais no gate local (OPS59):** ele roda **no CI** — no PR (selected/afetado, via `ci-scope.mjs`) e **full no `verify` do deploy manual** (gateia merge e deploy); local opcional para feedback antecipado: `pnpm test:e2e:affected` (gera `--no-deps` para runs filtrados — S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`).
|
||||
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
|
||||
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
|
||||
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# AGENT-OPS — operação do paradigma de agentes paralelos
|
||||
|
||||
Uma página. Norte: simplicidade (só Forgejo + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
|
||||
Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
|
||||
|
||||
## Ambientes de dados (test ladder — nunca misturar)
|
||||
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `ci.yml` (OPS53) |
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
|
||||
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```text
|
||||
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
|
||||
main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge com mudança de produção == site atualizado em ~30 min + suite/deploy)
|
||||
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
|
||||
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
|
||||
```
|
||||
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
|
||||
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR via API/MCP **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
### Dono do PR, dono do CI
|
||||
|
||||
|
|
@ -36,28 +36,29 @@ main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (hom
|
|||
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push para o **GitHub** (origin) |
|
||||
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) |
|
||||
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
|
||||
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
Labels (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
|
||||
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.
|
||||
|
||||
## Contrato de PR
|
||||
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `pnpm push -u origin HEAD` → PR via API/MCP (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/forgejo-pr-automerge.mjs`) espera o rollup `CI (PR) / checks` e mergea por rebase — não há `gh` em nenhuma máquina (OPS50), o safety net é quem espera os checks. **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `pnpm push -u origin HEAD` (GitHub) → PR via `node scripts/github-pr.mjs` (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/github-pr-automerge.mjs`) arma o **auto-merge nativo do GitHub por rebase** — o servidor espera o required check `CI (PR) / checks` e mergea; nada a armar por PR, não há `gh` (OPS50) e não há poll (OPS71). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo do auto-merge.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração).
|
||||
|
||||
## Registries compartilhados (serialização por arquivo, não por PR)
|
||||
|
||||
|
|
@ -67,39 +68,39 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
|
|||
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
|
||||
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
|
||||
|
||||
## CI por alvo
|
||||
## CI por alvo (GitHub Actions, OPS71)
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | 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 e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
|
||||
| `ci.yml` | schedule `*/30` / dispatch `main` | 2× service Postgres 17 | Janela fixa (OPS65): gate `gate` (host, main-only, sem pnpm) classifica mudança de produção desde a **revision do container rodando** no homeserver (`.dockerignore` = fonte da verdade; fail-open; incapaz de classificar → run **vermelho**); sem mudança → suite + deploy skipped, run verde; com mudança → job único `checks` sequencial (mesma mecânica X1) + job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53, guard "already deployed") |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
|
||||
| `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) | — | `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 |
|
||||
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation).
|
||||
`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.
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
|
||||
|
||||
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
||||
|
||||
### Secrets (humano, uma vez)
|
||||
### Secrets (humano, uma vez — repo GitHub)
|
||||
|
||||
| Secret | Uso |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`); também nos workflows pós-merge (`issue-done`, `plan-issue-ready`) e no safety net — o `GITHUB_TOKEN` nativo 403 em Issues e não tem write no repo (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) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
|
||||
`POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub): o `scripts/agent-pool.mjs` o ignora quando presente; não criar no Forgejo. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do Forgejo.
|
||||
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).
|
||||
|
||||
Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). **OPS64:** o CLI nunca confia no rollup sozinho — varre os statuses individuais e falha em qualquer `failure`/`error` (pin em `forgejoApi.unit.spec.ts`, cenário run 607: rollup verde com shard de e2e vermelho → throw). Com o desenho de job único do OPS62 (sem matrix, sem shards) o status do job `checks` já é o veredito honesto da cascata — o bug de agregação do parent de matrix deixou de existir estruturalmente. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (o check-run do `ci-pr.yml` — job único, veredito honesto da cascata inteira), `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 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.
|
||||
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).
|
||||
|
||||
## Cursor Cloud
|
||||
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. PR via `ManagePullRequest` (draft: false) — o merge continua pelo safety net do GitHub.
|
||||
|
||||
## Agent pool
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**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.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-ops71.md
Normal file
1
docs/changelog/2026-08-19-ops71.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
|
|
@ -1,40 +1,30 @@
|
|||
# Runbook: deploy do site 1313 no homeserver (OPS53, janela OPS65)
|
||||
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
|
||||
|
||||
O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
|
||||
com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
|
||||
Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo
|
||||
da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
|
||||
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
|
||||
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
|
||||
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
|
||||
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
|
||||
|
||||
## Gatilho e fluxo
|
||||
|
||||
1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada
|
||||
push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via
|
||||
`workflow_dispatch` (deploy manual, imediato, sempre full suite).
|
||||
2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando**
|
||||
no homeserver (`docker inspect` → label `org.opencontainers.image.revision`
|
||||
do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback
|
||||
falho, pois é trocado antes do rollout) e classifica "mudança de produção"
|
||||
com o `.dockerignore` como fonte da verdade (o que não entra na imagem não
|
||||
justifica build novo; `docs/`, `.agents/`, `.env*` viram skip;
|
||||
`scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção —
|
||||
conservador, qualquer dúvida roda a suite).
|
||||
- Deployed == HEAD → suite e deploy **skipped**; run verde.
|
||||
- Sem revision conhecida (label ausente, container sem label) → fail-open:
|
||||
suite + deploy rodam na janela (run verde; comportamento conservador).
|
||||
- Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e
|
||||
o run fica **vermelho** — nunca blackout silencioso.
|
||||
3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks).
|
||||
4. Job `deploy` (runs-on `host`, na **workstation**) roda
|
||||
`ssh homeserver "bash -s -- <sha>" < scripts/deploy-homeserver.sh`.
|
||||
O script é **idempotente**: se o container rodando já tem a revision do SHA
|
||||
(ex.: dispatch duplicado), sai verde "already deployed" sem rebuild.
|
||||
5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
|
||||
senão skip) → `flock` (serializa) → guard "already deployed" (revision do
|
||||
container) → **build do migrator** (o estágio migrator não roda `next
|
||||
build` — builda mesmo contra o schema antigo; BuildKit, secrets do
|
||||
`~/stack/teqo-1313.env`) → push/tag do migrator em `localhost:5000` →
|
||||
swap dos tags de imagem no `~/stack/docker-compose.yml` (backup antes) →
|
||||
**migrations**
|
||||
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
|
||||
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||||
check-test-locations → lint → format → typecheck → knip → cycles → unit →
|
||||
int (migrate+seed nos services) → build → e2e full.
|
||||
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
|
||||
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
|
||||
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
|
||||
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
|
||||
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
|
||||
conta minutos hosted).
|
||||
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de
|
||||
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público —
|
||||
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard
|
||||
"already deployed" (revision do container rodando) → **build do migrator**
|
||||
(o estágio migrator não roda `next build` — builda mesmo contra o schema
|
||||
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do
|
||||
migrator em `localhost:5000` → swap dos tags de imagem no
|
||||
`~/stack/docker-compose.yml` (backup antes) → **migrations**
|
||||
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
|
||||
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
|
||||
migrado — o `next build` da geração estática lê o schema novo, OPS66;
|
||||
|
|
@ -43,27 +33,59 @@ build` — builda mesmo contra o schema antigo; BuildKit, secrets do
|
|||
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
|
||||
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
|
||||
login-options, `api/revalidate` com o secret real).
|
||||
6. Falha = job vermelho no commit; nada é publicado pela metade.
|
||||
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||||
após o swap).
|
||||
|
||||
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
|
||||
run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
|
||||
o veredito da classificação, e que um dispatch manual roda suite + deploy
|
||||
(idempotência: segundo dispatch no mesmo SHA sai "already deployed"). Isso
|
||||
prova ao vivo a paridade Forgejo de `needs.<gate>.outputs` com gate falho
|
||||
(suite roda; deploy pula; run vermelho).
|
||||
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
|
||||
deploy manual e confira no log do job `deploy` o `set-url` do workspace
|
||||
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
|
||||
"already deployed" num segundo dispatch do mesmo SHA.
|
||||
|
||||
## Onde roda cada coisa
|
||||
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **workstation** (16c/61GB) | runner Forgejo (`workstation-runner`); job `deploy` roda no host (chave SSH `~/.ssh/id_ed25519` → `ssh homeserver`) |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; workspace `~/teqo-deploy` (clone do Forgejo local) |
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado no cutover OPS71 — registrar em infra-solla); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
|
||||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||||
|
||||
Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
|
||||
Settings → Actions.
|
||||
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
|
||||
clone do workspace não precisa de credencial. No GitHub ficam apenas os
|
||||
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
|
||||
`CURSOR_API_KEY` (archive helper, dormente).
|
||||
|
||||
## Rollback
|
||||
## Cutover OPS71 (passos manuais, ordem)
|
||||
|
||||
1. Remotes locais (config compartilhada pelos worktrees):
|
||||
`git remote set-url origin git@github.com:fsolla/teqo.git` e
|
||||
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
|
||||
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
|
||||
main; o tracker (Issues) segue vivo por API.
|
||||
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
|
||||
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
|
||||
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
|
||||
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
|
||||
General, defina fork PR workflows como não executar (ou exigir aprovação).
|
||||
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
|
||||
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
|
||||
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
|
||||
`CI (PR) / checks`, 0 reviews, `enforce_admins: true`. **Antes do primeiro
|
||||
push** (senão o PR mergearia sem required check).
|
||||
5. **Desligar o runner do Forgejo** na workstation (ex.:
|
||||
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
|
||||
validado; para o schedule do `ci.yml` antigo.
|
||||
6. Instalar o runner self-hosted do GitHub no homeserver (labels
|
||||
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
|
||||
registrar em infra-solla: `STATE.md`).
|
||||
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
|
||||
deploy manual de validação.
|
||||
|
||||
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
|
||||
abrir PRs no Forgejo (os workflows de `.forgejo/workflows/` seguem no repo
|
||||
até a Fase 2) — remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
|
||||
|
||||
## Rollback (do deploy)
|
||||
|
||||
A imagem anterior continua local e no registry (`localhost:5000/teqo-1313:<sha>`
|
||||
— o registry nunca deleta). O compose anterior fica em
|
||||
|
|
@ -90,39 +112,40 @@ roda após migrator build+push+swap ok; rollout só após runner build ok). Desd
|
|||
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
|
||||
novo): um build do runner que falhe depois do migrate deixa o banco à frente
|
||||
do código — migrations são append-only e revisadas antes do deploy (checklist);
|
||||
a correção se re-mergeia e a próxima janela completa o deploy.
|
||||
a correção se re-mergeia e o próximo deploy completo publica.
|
||||
|
||||
## Falhas conhecidas
|
||||
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Gate `gate` vermelho (erro de git/`.dockerignore`) | SHA deployado ilegível para o git (ex.: história reescrita) ou `.dockerignore` corrompido no checkout | Fail-open: suite roda; deploy pula (nunca deploy às cegas). Corrigir a causa e esperar a próxima janela (auto-cura). Se `ssh homeserver` estiver fora, o gate lê revision vazia e roda tudo (verde) — checar tailnet se a suite roda sem deploy recorrente |
|
||||
| Sem revision no container (label ausente no compose) | O compose do homeserver não define `org.opencontainers.image.revision` (deploy pré-OPS53 ou edição manual) | Degradação conservadora: toda janela roda suite + rebuild (verde). Corrigir adicionando o label ao serviço `teqo-1313` no compose e re-deployando |
|
||||
| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) |
|
||||
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
|
||||
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
|
||||
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
|
||||
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
|
||||
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
|
||||
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
|
||||
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Job verde sem deploy ("stale run") | Push mais novo venceu; o dispatch apontava um SHA que não é mais o HEAD do main do GitHub | Nada a fazer — dispatch novo no main atual |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
|
||||
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
|
||||
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
|
||||
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
|
||||
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
|
||||
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
|
||||
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
|
||||
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
|
||||
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
|
||||
|
||||
## Segurança (decisão deliberada)
|
||||
|
||||
- O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
|
||||
Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no
|
||||
`ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a
|
||||
`github.ref == 'refs/heads/main'` (dispatch em branch não roda código do
|
||||
branch no host — a suite roda fail-open em containers com secrets de teste).
|
||||
`main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
|
||||
workflows no Forgejo (default off — verificado).
|
||||
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
|
||||
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
|
||||
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
|
||||
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
|
||||
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
|
||||
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
|
||||
gate + setting do repo).
|
||||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||||
build-secrets; envs só no homeserver.
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
|
||||
- `.github/workflows/deploy.yml` — verify → deploy
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
|
||||
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
|
||||
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"
|
||||
|
|
|
|||
130
docs/plans/ops71-ci-github-actions-tracker-forgejo-impl.md
Normal file
130
docs/plans/ops71-ci-github-actions-tracker-forgejo-impl.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Impl: CI/PR do Forgejo Actions de volta para o GitHub Actions — tracker de Issues permanece no Forgejo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #97
|
||||
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md
|
||||
Appetite restante: herdado (~2–3 dias eng; este impl é o item inteiro — OPS72 roda depois com `depends`)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** código/PR/CI no GitHub Actions (mesma cascata de verificações,
|
||||
job único `checks`), tracker de Issues no Forgejo por API, deploy **manual**
|
||||
(`workflow_dispatch`) com dois jobs (`verify` hosted full → `deploy`
|
||||
self-hosted no homeserver), rollup `CI (PR) / checks` como required check do
|
||||
GitHub, workstation livre da suíte.
|
||||
- **O que NÃO negociar:** tracker (Issues/labels/claims) permanece no Forgejo;
|
||||
estrutura de job único (OPS62) preservada — nada de matrix; deploy NÃO volta
|
||||
a ser automático; sem migrar Issues; sem segundo deploy (nada de Vercel);
|
||||
nunca merge com CI vermelho; `pnpm push` continua sendo o caminho canônico.
|
||||
- **O que reavaliar:** a intenção sugere "auto-merge nativo do GitHub ou
|
||||
safety net equivalente" e "`gh workflow run` (ou API GitHub)". Reavaliado:
|
||||
`gh` **não existe** em nenhuma máquina (OPS50) e a filosofia do repo é
|
||||
plain-Node stdlib — os helpers novos são scripts Node zero-dep sobre a REST
|
||||
do GitHub, no mesmo molde de `forgejo-api.mjs`. O auto-merge é o **nativo do
|
||||
GitHub** (GraphQL `enablePullRequestAutoMerge`, rebase) — o servidor espera o
|
||||
required check; o OPS64 pin (varrer statuses individuais) era defesa contra o
|
||||
rollup mentiroso do runner Forgejo, que **não existe** no GitHub (o check-run
|
||||
`CI (PR) / checks` de um job único é o veredito honesto, e o auto-merge nativo
|
||||
só mergeia com required checks verdes — garantia do servidor).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W[worktree local] -->|pnpm push -u origin HEAD| GH[github.com/fsolla/teqo]
|
||||
GH -->|PR Ready base main Closes #N| CI["ci-pr.yml — job único checks (mesma cascata OPS62)"]
|
||||
CI -->|required check CI (PR) / checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"]
|
||||
AM -->|merge| MAIN[main no GitHub]
|
||||
MAIN -->|PR closed merged| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN"]
|
||||
MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite]
|
||||
V -->|needs| D["deploy runs-on self-hosted homeserver — deploy-homeserver.sh local"]
|
||||
D --> P[(teqo-1313 produção)]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — origin vira GitHub; Forgejo como remote secundário congelado; TEQO_REPO_URL → GitHub (repo público, sem auth).** `origin/main` é o base dos gates locais (ci-scope/gate-ci) — precisa apontar para o repo que recebe os merges, senão o diff local compara contra um main defasado. O repo GitHub é público (verificado: `private: false`) → o homeserver clona sem credencial.
|
||||
- **B — auto-merge via GraphQL nativo.** GitHub espera o required check no servidor; nenhum poll; sem semáforo portado. Draft `cursor/*` → Ready via PATCH; draft não-`cursor/*` = veto (OPS57, preservado).
|
||||
- **C — deploy.yml com `verify` (hosted, suíte full sem skips) → `deploy` (needs, self-hosted homeserver) executando `deploy-homeserver.sh <sha>` localmente (sem SSH).**
|
||||
- **D — `forgejo-issue-transition.mjs` / `agent-promote-related-on-merge.mjs` ganham `PR_BODY` env** (mecanismo que `agent-promote-related` já usa): o body do PR vem do evento GitHub; o script flipa no Forgejo por API sem ler o PR do Forgejo (PR lá não existe mais).
|
||||
- **E — `configure-branch-protection.mjs` reescrito para a REST do GitHub** com lib pura `github-branch-protection.mjs` (regra desejada + drift), no mesmo molde de `branch-protection.mjs`; `enforce_admins: true` preserva o invariante "nunca merge com CI vermelho" inclusive para admin (o rule-based do Forgejo valia para todos).
|
||||
|
||||
**Recomendação:** A+B+C+D+E — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente) e o aceite da intenção exige exatamente esse desenho; nada disso exige binário novo (`gh`) nem semáforo reimplementado.
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **Instalar `gh` e usar `gh pr create/merge/run`** — binário novo em todas as máquinas + workflows, contra a convenção zero-dep do repo; a REST cobre tudo.
|
||||
- **Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único** — reimplementa o semáforo que a intenção manda cortar; o OPS64 pin era contra o bug do rollup do Forgejo, inexistente no GitHub com job único + auto-merge nativo.
|
||||
- **Mirror ativo do Forgejo (`pnpm push` para dois remotes / sync pós-merge)** — nada lê o git do Forgejo em runtime (deploy usa TEQO_REPO_URL=GitHub; flips são API); o repo Forgejo congela em main no ponto do cutover (tracker intacto por API, anti-goal "nunca fecha o tracker" preservado).
|
||||
- **Deletar os arquivos do Forgejo nesta entrega** — decisão do gate (2026-08-19): o sistema GitHub Actions nasce PRIMEIRO e é validado ao vivo (PR real com CI verde → auto-merge → flips → deploy manual); a remoção de `.forgejo/workflows/` + scripts mortos é **entrega sucessora**, só depois de tudo funcionando. O repo Forgejo congelado no main pré-cutover mantém os workflows como rollback (reversível: religar runner + PRs lá); apagar na primeira entrega queimaria a via de volta.
|
||||
- **Manter `ci-classify-production.mjs` como check informativo no dispatch** — o hosted não alcança o homeserver (sem SSH) e o `verify` roda a suíte full sem skips; a classificação não muda nada → removido só na entrega de remoção.
|
||||
- **`TEQO_REPO_URL` → Forgejo local com mirror** — dependência extra de sync; o repo GitHub é público, clone direto sem auth.
|
||||
- **Fork-PR no CI** — repo público: fork PR rodaria a suíte full em minutes hosted de graça; `if:` same-repo no job `checks` (mesma guarda de RCE dos outros workflows).
|
||||
|
||||
## Cutover em duas fases (gate 2026-08-19)
|
||||
|
||||
O GitHub é **só** o host do Actions; o Forgejo permanece o **tracker principal** (Issues, labels, claims, flips — tudo via API, intacto). A remoção dos arquivos do Forgejo só acontece após validação ao vivo do fluxo GitHub:
|
||||
|
||||
1. **Fase 1 (esta entrega — OPS71):** cria todo o sistema GitHub Actions (workflows, scripts, libs, docs). **Nada do Forgejo é apagado** — `.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` + specs permanecem no repo (dormentes: o repo Forgejo não recebe mais push).
|
||||
2. **Cutover manual (humano, nesta ordem — (a)–(c) ANTES do push do OPS71, senão o PR mergearia sem required check):** (a) remotes locais (`origin` → GitHub, `forgejo` secundário); (b) PAT `GITHUB_TOKEN` local + secrets no GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`) + `allow_auto_merge: true`; (c) `pnpm configure:branch-protection` (GitHub); (d) **desligar o runner do Forgejo** (reversível — é o que para o schedule do `ci.yml` antigo, que não tem mais sentido e disputaria a workstation; pode ser logo após o primeiro PR GitHub validado); (e) runner self-hosted no homeserver (deploy; não bloqueia PRs — pode ficar para depois); (f) push do OPS71 → PR no GitHub → CI verde → auto-merge → flip da #97 (valida os flips no merge do próprio OPS71) → deploy manual dispatch quando o runner do homeserver estiver pronto.
|
||||
3. **Fase 2 (entrega sucessora, registrada no fechamento desta Issue):** remove `.forgejo/workflows/*`, scripts mortos e docs residuais do fluxo antigo. Só depois do fluxo GitHub comprovado de ponta a ponta.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/ci-pr.yml`** (novo): port do `.forgejo/workflows/ci-pr.yml` — job único `checks`, mesmos steps/guards/services (`postgres-int`/`postgres-build` por nome na rede do job), skips via `ci-scope.mjs` (env `GITHUB_BASE_REF` idêntico). Deltas GitHub: `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }` (fail-fast nativo em push novo — o OPS62 não cancelava só porque o Forgejo não tem API; aqui é grátis), `if:` same-repo no job (fork não queima minutes), `permissions: contents: read` (default), e2e selected preservado (OPS72 ajusta a política depois — `depends`).
|
||||
- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs — ref default main). `verify` (ubuntu-latest, timeout 50): `check-test-locations` → lint → format → typecheck → knip → cycles → unit → int (migrate+seed nos services) → build → e2e **full** (4 workers; guardas de contrato de PR ficam de fora — não são verificação de produção). `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`, timeout 60, `permissions: contents: read`): checkout → `bash scripts/deploy-homeserver.sh "$GITHUB_SHA"`.
|
||||
- **`scripts/deploy-homeserver.sh`**: `TEQO_REPO_URL` default → `https://github.com/fsolla/teqo.git`; no workspace existente, `git remote set-url origin "$TEQO_REPO_URL"` idempotente antes do fetch (o clone velho aponta para o Forgejo local); doc de invocação: execução local no runner (não mais pipe via ssh) — o `</dev/null` no `run --rm` do migrate permanece. Fluxo interno intacto (HEAD guard, flock, already-deployed, migrator→migrate→runner→rollout, smoke).
|
||||
- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo) + **`scripts/github-pr-automerge.mjs`** (novo, plain Node) + **`scripts/lib/github-api.mjs`** (novo, REST+GraphQL zero-dep): lê PR (REST); base != main → skip; draft `cursor/*` → `PATCH draft:false`; draft outro → skip (veto OPS57); senão GraphQL `enablePullRequestAutoMerge(mergeMethod: REBASE)` no `node_id` do PR. Erro do GraphQL → exit 1 (job vermelho, visível). `permissions: { contents: write, pull-requests: write }`.
|
||||
- **`scripts/github-pr.mjs`** (novo, plain Node): criação de PR para agentes/humano — `GITHUB_TOKEN` env; `--head <branch> --title <t> --body-file <f>` (Ready, base `main` — sem opção draft, regra do repo). Substitui o `forgejo_create_pull_request` do MCP no fluxo de entrega.
|
||||
- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `DESIRED_RULE` GitHub (`required_status_checks.checks = [{ context: 'CI (PR) / checks' }]`, strict=false, 0 reviews, `enforce_admins: true`, sem restrictions) + `ruleMatches`/`planBranchProtectionRule` (mesmo contrato do atual).
|
||||
- **`scripts/configure-branch-protection.mjs`**: reescrito para a REST do GitHub (`PUT /repos/{owner}/{repo}/branches/main/protection`), idempotente (read → plan → apply → verify), `--dry-run` preservado.
|
||||
- **`scripts/forgejo-issue-transition.mjs`**: aceita `PR_BODY` env (body do PR vem do evento GitHub); sem PR_BODY mantém a leitura via API do Forgejo (fallback — os workflows do Forgejo ainda estão vivos até a Fase 2); comentário do flip atualizado ("deploy manual via dispatch, ver AGENT-OPS").
|
||||
- **`.github/workflows/issue-done-on-main-merge.yml` / `plan-issue-ready-on-main-merge.yml`** (novos): `pull_request: closed` + `if:` merged && base main && **same-repo** (guarda RCE: checkout de fork + PAT = RCE — preservada); `PR_BODY: ${{ github.event.pull_request.body }}`; `FORGEJO_API_TOKEN` secret; sem `GITHUB_TOKEN` de Issues do GitHub (flip é no Forgejo).
|
||||
- **`.github/workflows/archive-cursor-agent.yml`** (novo): port direto do `.forgejo/workflows/` (CURSOR_API_KEY).
|
||||
- **`docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/skills/work-issue/` (SKILL + execution-pipeline), `.agents/skills/agent-work-issue/SKILL.md`, `.agents/skills/capture-review-debts/SKILL.md` (linha do fechamento), `AGENTS.md`, `docs/ops/teqo-1313-deploy.md`**: fluxo de PR/CI/merge/deploy reescrito para GitHub (canônico); seção de transição nomeando a Fase 2; tabela de secrets (GitHub agora: `FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, local `GITHUB_TOKEN` PAT); passos manuais do cutover na ordem (incl. desligar o runner do Forgejo e por quê — o schedule do `ci.yml` antigo pararia de ter sentido).
|
||||
- **Preservados nesta entrega (Fase 2 remove):** `.forgejo/workflows/*` (6 arquivos), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` + specs (`ciClassifyProduction`, `dockerignoreMatch`, `branchProtection`) — dormentes, documentados como rollback e alvo da entrega de remoção.
|
||||
- **Testes novos:** `tests/unit/githubApi.unit.spec.ts` (normalização de shapes + endpoints REST/GraphQL, molde do `forgejoApi.unit.spec.ts`), `tests/unit/githubBranchProtection.unit.spec.ts` (regra desejada + drift, molde do `branchProtection.unit.spec.ts`), e pure-functions da política de draft/skip do automerger (`scripts/lib/github-pr-flow.mjs` + spec — o pin OPS57/OPS64 do jeito GitHub).
|
||||
- **Migration:** sem migration (nenhum schema).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable N/A.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Libs + scripts base** — `github-api.mjs`, `github-pr-flow.mjs` (puro), `github-branch-protection.mjs` (puro), `github-pr.mjs`, `github-pr-automerge.mjs`, `forgejo-issue-transition.mjs` (PR_BODY), `configure-branch-protection.mjs` (GitHub) + specs unit. Gate: `pnpm test:unit`.
|
||||
2. **Workflows** — `.github/workflows/*` (6). Nada do `.forgejo/workflows/` é tocado nesta fase (Fase 2 do cutover). Gate: `pnpm gate:fast` + `pnpm check:cycles` + knip.
|
||||
3. **Docs/skills + changelog** — AGENTS.md, AGENT-OPS, agent-pr-workflow.mdc, skills, runbook (com seção de transição), `docs/changelog/2026-08-19-ops71.md` + `pnpm changelog:build`.
|
||||
4. **Gates finais + entrega** — `pnpm gate:ci` completo (unit+int+build), format, knip, cycles; `pnpm push -u origin HEAD` → PR no GitHub via `node scripts/github-pr.mjs` → CI verde no GitHub → auto-merge → flip da #97. **Passos manuais do cutover listados para o humano** (Fase 1 do cutover: remotes, secrets, branch protection, runner homeserver, shutdown runner Forgejo) — o PR em si não depende deles (CI roda no GitHub com secrets de teste), mas a ordem recomendada é: remotes → secrets → configure → push → PR.
|
||||
|
||||
## Fase 2 — entrega sucessora (pós-validação)
|
||||
|
||||
Registrada no fechamento desta Issue (capture-review-debts): remove `.forgejo/workflows/*`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`, `dockerignore.mjs` (+ specs órfãs) e qualquer doc residual do fluxo Forgejo. Critério de entrada: um ciclo completo validado no GitHub (PR → CI → auto-merge → flip → deploy manual).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Portar `waitForChecks`/statuses para o GitHub — não há; auto-merge nativo.
|
||||
- Re-engenharia do `deploy-homeserver.sh` (fluxo docker/compose/migrate) — só a origem do clone e o modo de invocação mudam.
|
||||
- Sincronizar o repo Forgejo com main — congelado (decidido, anti-goal do tracker preservado).
|
||||
- Migrar Issues/labels/claims — anti-goal explícito.
|
||||
- OPS72 (e2e local afetado) — item sucessor com `depends: [OPS71]`.
|
||||
- Pool/`agent-pool*` — dormente (OPS65); o texto `gh pr` em `agent-pool-prompt.mjs` é de feature morta — deixo como está (fora do serializes).
|
||||
- Secrets de GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, `GITHUB_TOKEN` PAT local) — passo manual documentado, não código.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Nome do check-run `CI (PR) / checks`:** o workflow `name: CI (PR)` + job `checks` geram exatamente esse check-run no GitHub; o required check usa o mesmo literal — pin no spec da branch protection e verificação ao vivo no primeiro PR (se o nome divergir, o configure aponta o drift e o PR não mergea — fail-closed, nunca merge sem verificação).
|
||||
- **Auto-merge GraphQL com `GITHUB_TOKEN`:** permissões declaradas (`contents`+`pull-requests: write`) cobrem PATCH draft e a mutation; se o GraphQL recusar (ex.: auto-merge desabilitado no repo), o job fica vermelho com a mensagem — mitigação: passo manual `allow_auto_merge: true` no repo (Settings ou API) listado no cutover.
|
||||
- **PR com conflito:** auto-merge fica armado e o PR espera — igual ao hoje (waitForChecks lançava "não mergeável"); nenhum merge errado é possível (servidor).
|
||||
- **Repo público → fork PR:** `if:` same-repo no job `checks` + setting de fork workflows no repo (passo manual) — fork não queima minutes nem acessa secrets.
|
||||
- **Runner do homeserver ainda não instalado:** o `deploy` fica indisponível até o passo manual; PRs seguem mergeando (CI hosted não depende do runner). O PR deste item não publica nada sozinho — o deploy é dispatch manual.
|
||||
- **Schedule do `ci.yml` antigo rodando na workstation durante a transição:** com o repo Forgejo congelado, o gate do Forgejo lerá um deployed SHA inexistente no clone → fail-open vermelho → suíte full a cada 30 min na workstation. Mitigado: o cutover desliga o runner do Forgejo logo após o merge (passo (d), reversível) — antes do primeiro deploy via GitHub.
|
||||
- **`gate:ci` local vs `origin/main`:** o remote muda para GitHub; sem o passo manual dos remotes, o gate local compara contra o Forgejo defasado → risco de diff falso. Mitigado: o passo de remotes é o PRIMEIRO do cutover e o runbook avisa; o `*-impl.md` documenta o comando exato.
|
||||
- **TEQO_REPO_URL no homeserver:** clone velho com origin Forgejo → `set-url` idempotente no script cobre; repo público → sem credencial.
|
||||
- **Knip com os scripts do Forgejo preservados:** nada órfão é criado nesta entrega (todos os scripts seguem referenciados pelos YAMLs do `.forgejo/workflows/`, que permanecem) — sem risco de knip flagrar a deleção adiada.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo, deploy manual verify→self-hosted, rollup required check)
|
||||
- [x] Cutover em duas fases (gate 2026-08-19): sistema GitHub criado e validado primeiro; arquivos do Forgejo preservados nesta entrega, remoção em entrega sucessora pós-validação
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem schema; plain Node stdlib nos helpers de CI; zero-dep nos workflows)
|
||||
- [x] Testes de domínio previstos: github-api (normalização/endpoints), github-branch-protection (drift), github-pr-flow (draft policy/skips — pin OPS57/OPS64 no jeito GitHub)
|
||||
|
|
@ -1,38 +1,45 @@
|
|||
/**
|
||||
* Drift repair for branch protection (main-only) — Forgejo. Idempotent: reads
|
||||
* the existing rules and creates / updates / no-ops (never duplicates).
|
||||
* Drift repair for `main` branch protection — GitHub (OPS71). Idempotent:
|
||||
* reads the existing rule and creates / updates / no-ops (never duplicates).
|
||||
*
|
||||
* pnpm configure:branch-protection # apply
|
||||
* pnpm configure:branch-protection -- --dry-run
|
||||
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection # apply
|
||||
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection -- --dry-run
|
||||
*
|
||||
* main: required status check `CI (PR) / checks*` (the ci-pr cascade rollup —
|
||||
* OPS61: Forgejo matches the contexts as globs against the real status
|
||||
* contexts like `CI (PR) / checks (pull_request)`, so the old literal `checks`
|
||||
* would match nothing), strict=false, 0 reviews. Feature PRs auto-merge when
|
||||
* that context is green; the server rule is the final defense against merges
|
||||
* before the cascade settles (the waitForChecks rollup gate is the client
|
||||
* half).
|
||||
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
|
||||
* Desired rule: required check-run `CI (PR) / checks` (the ci-pr cascade —
|
||||
* OPS62), strict=false, 0 reviews, `enforce_admins: true` (nobody, admin
|
||||
* included, merges with a red required check). Feature PRs auto-merge when
|
||||
* that check is green; the server rule is the final defense against every
|
||||
* merge path, manual API merges included.
|
||||
*/
|
||||
|
||||
import { parseArgs } from './lib/agent-forgejo.mjs'
|
||||
import { DESIRED_RULE, planBranchProtectionRule } from './lib/branch-protection.mjs'
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs'
|
||||
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set())
|
||||
const dryRun = flags['dry-run'] === true
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const dryRun = flags.dryRun === true
|
||||
const branch = 'main'
|
||||
|
||||
const api = createApi({})
|
||||
const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
|
||||
const ruleName = DESIRED_RULE.rule_name
|
||||
|
||||
const existingRules = await api.listBranchProtections()
|
||||
const existing =
|
||||
existingRules.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
|
||||
const repo = process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
|
||||
const existing = await api.getBranchProtection(branch)
|
||||
const { action } = planBranchProtectionRule(existing)
|
||||
|
||||
console.log(
|
||||
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${ruleName}": ${
|
||||
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${
|
||||
action === 'noop'
|
||||
? 'já conforme — nada a fazer'
|
||||
: action === 'update'
|
||||
|
|
@ -42,34 +49,26 @@ console.log(
|
|||
)
|
||||
|
||||
if (action === 'noop') {
|
||||
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(DESIRED_RULE)}`)
|
||||
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const payload = { ...DESIRED_RULE, branch_name: ruleName }
|
||||
const target = action === 'create' ? '/branch_protections' : `/branch_protections/${ruleName}`
|
||||
const method = action === 'create' ? 'POST' : 'PATCH'
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] ${method} /repos/${repo}${target} ${JSON.stringify(payload)}`)
|
||||
console.log(
|
||||
`[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
await api.updateBranchProtection(ruleName, payload)
|
||||
} else {
|
||||
await api.editBranchProtection(ruleName, payload)
|
||||
}
|
||||
await api.updateBranchProtection(DESIRED_RULE, branch)
|
||||
|
||||
const after = await api.listBranchProtections()
|
||||
const applied =
|
||||
after.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
|
||||
if (!applied) {
|
||||
const after = await api.getBranchProtection(branch)
|
||||
if (!after) {
|
||||
console.error(
|
||||
`[configure:branch-protection] FALHOU: ${method} ${target} respondeu mas a regra "${ruleName}" não aparece no servidor.`,
|
||||
`[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" não aparece no servidor.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(applied)}`,
|
||||
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
#
|
||||
# Deploy teqo-1313 to the homeserver (OPS53).
|
||||
#
|
||||
# Runs ON the homeserver, piped by the CI deploy job:
|
||||
# ssh homeserver "bash -s -- <commit-sha>" < scripts/deploy-homeserver.sh
|
||||
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy job
|
||||
# of .github/workflows/deploy.yml):
|
||||
# bash scripts/deploy-homeserver.sh <commit-sha>
|
||||
# (The Forgejo-era invocation `ssh homeserver "bash -s -- <sha>" < script`
|
||||
# is gone — no SSH hop, no workstation involvement.)
|
||||
#
|
||||
# Flow: HEAD guard (only the current main HEAD deploys) -> flock
|
||||
# serialization -> workspace fetch at <sha> -> docker login (local registry)
|
||||
|
|
@ -17,14 +20,16 @@
|
|||
# the running site untouched and the job red.
|
||||
#
|
||||
# Environment defaults assume the homeserver layout: stack under
|
||||
# $HOME/stack, repo cloned from the local Forgejo. Secrets are sourced from
|
||||
# the chmod-600 env files and never echoed (no `set -x`, passwords only via
|
||||
# --password-stdin / build secrets).
|
||||
# $HOME/stack, repo cloned from github.com/fsolla/teqo (public — no
|
||||
# credentials; the pre-OPS71 clone pointed at the local Forgejo and is
|
||||
# re-pointed idempotently below). Secrets are sourced from the chmod-600 env
|
||||
# files and never echoed (no `set -x`, passwords only via --password-stdin /
|
||||
# build secrets).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}"
|
||||
TEQO_REPO_URL="${TEQO_REPO_URL:-http://localhost:3000/fsolla/teqo.git}"
|
||||
TEQO_REPO_URL="${TEQO_REPO_URL:-https://github.com/fsolla/teqo.git}"
|
||||
STACK_DIR="${STACK_DIR:-$HOME/stack}"
|
||||
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}"
|
||||
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}"
|
||||
|
|
@ -88,6 +93,14 @@ if [ ! -d "$WORKSPACE_DIR/.git" ]; then
|
|||
say "cloning $TEQO_REPO_URL into $WORKSPACE_DIR"
|
||||
git clone "$TEQO_REPO_URL" "$WORKSPACE_DIR"
|
||||
else
|
||||
# OPS71: the pre-cutover clone pointed at the local Forgejo — re-point
|
||||
# idempotently so the HEAD guard and the fetch compare against the repo
|
||||
# that actually received the merge.
|
||||
current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)"
|
||||
if [ -n "$current_url" ] && [ "$current_url" != "$TEQO_REPO_URL" ]; then
|
||||
say "updating workspace origin: $current_url → $TEQO_REPO_URL"
|
||||
git -C "$WORKSPACE_DIR" remote set-url origin "$TEQO_REPO_URL"
|
||||
fi
|
||||
git -C "$WORKSPACE_DIR" fetch origin main
|
||||
fi
|
||||
git -C "$WORKSPACE_DIR" checkout --detach "$SHA" || fatal "checkout of $SHA failed"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
|
||||
* `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment.
|
||||
* Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge.
|
||||
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
|
||||
* on), so the workflow only needs the PR number.
|
||||
*
|
||||
* OPS71: the PR now lives on GitHub. The workflow passes the PR body via the
|
||||
* `PR_BODY` env (from the GitHub event payload) and the script flips Issues
|
||||
* on the Forgejo tracker by API — no Forgejo PR read when PR_BODY is present.
|
||||
* Without PR_BODY the script falls back to reading the PR from the Forgejo
|
||||
* API (the Forgejo-era workflows stay alive until the OPS71 Fase 2 removal).
|
||||
*
|
||||
* OPS61: a failed flip exits 1 — the flip is the whole purpose of this
|
||||
* workflow, and the 403 era proved that a swallowed error ends the job
|
||||
|
|
@ -11,6 +15,7 @@
|
|||
* (the loop does not abort), but the job goes red so the breakage is visible.
|
||||
*
|
||||
* node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
* PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
|
@ -37,7 +42,11 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|||
}
|
||||
|
||||
const api = createApi({})
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const bodyFromEnv = process.env.PR_BODY
|
||||
const pr =
|
||||
typeof bodyFromEnv === 'string'
|
||||
? { merged: true, body: bodyFromEnv }
|
||||
: await api.getPullRequest(prNumber)
|
||||
if (!pr) {
|
||||
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
|
||||
process.exit(1)
|
||||
|
|
@ -62,7 +71,7 @@ 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 segue o verificador \`ci.yml\` (gated).`,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
|
||||
)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
|
||||
} catch (error) {
|
||||
|
|
|
|||
69
scripts/github-pr-automerge.mjs
Normal file
69
scripts/github-pr-automerge.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* 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).
|
||||
*
|
||||
* Unlike the Forgejo-era CLI (forgejo-pr-automerge.mjs), there is NO poll
|
||||
* loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges when
|
||||
* every required check (incl. `CI (PR) / checks`) is green and the PR is
|
||||
* mergeable. The OPS64 "never trust the rollup" pin is structural here: a
|
||||
* single sequential job posts one honest check-run, and the server enforces
|
||||
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked
|
||||
* ready — any other draft is the actor's veto (skip, exit 0).
|
||||
*
|
||||
* Idempotent: already merged / closed / non-main base → no-op success.
|
||||
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so
|
||||
* the job goes red and the situation is visible.
|
||||
*
|
||||
* node scripts/github-pr-automerge.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { decideAutomergeAction } from './lib/github-pr-flow.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/github-pr-automerge.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const verdict = decideAutomergeAction(pr)
|
||||
|
||||
if (verdict.action === 'skip') {
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: skip (${verdict.reason})`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (verdict.action === 'mark-ready') {
|
||||
console.log(`[github-pr-automerge] PR #${prNumber} é draft cursor/* — marcando Ready`)
|
||||
await api.markPullRequestReady(prNumber)
|
||||
}
|
||||
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
|
||||
await api.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`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
64
scripts/github-pr.mjs
Normal file
64
scripts/github-pr.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — create a GitHub PR (Ready, never draft) for
|
||||
* agent deliveries. Replaces the Forgejo MCP `create_pull_request` in the
|
||||
* delivery flow (OPS71: PRs live on GitHub; the tracker stays on Forgejo).
|
||||
*
|
||||
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs \
|
||||
* --head <branch> --title "<título>" --body-file <path>
|
||||
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <b> --title <t> --body "Closes #N"
|
||||
*
|
||||
* The repo rule "PRs are Ready, never draft" is structural: there is no
|
||||
* `--draft` flag. Base defaults to `main`.
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const head = flags.head
|
||||
const title = flags.title
|
||||
const base = flags.base ?? 'main'
|
||||
|
||||
if (!head || !title) {
|
||||
console.error(
|
||||
'Usage: node scripts/github-pr.mjs --head <branch> --title <t> [--base main] --body "<text>" | --body-file <path>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let body = flags.body
|
||||
if (flags.bodyFile) {
|
||||
try {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
body = readFileSync(flags.bodyFile, 'utf8')
|
||||
} catch (error) {
|
||||
console.error(`[github-pr] não consegui ler --body-file: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (typeof body !== 'string' || body.length === 0) {
|
||||
console.error('[github-pr] body vazio — passe --body ou --body-file')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.createPullRequest({ head, base, title, body })
|
||||
console.log(`[github-pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`)
|
||||
} catch (error) {
|
||||
console.error(`[github-pr] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
264
scripts/lib/github-api.mjs
Normal file
264
scripts/lib/github-api.mjs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* GitHub REST + GraphQL client for the CI/PR scripts (`github-pr*`, branch
|
||||
* protection). Zero dependencies — plain Node `fetch` — so it also runs
|
||||
* inside GitHub Actions jobs without `pnpm install`.
|
||||
*
|
||||
* Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in
|
||||
* workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`.
|
||||
*
|
||||
* Retry (same policy as forgejo-api OPS67): transient failures retry with
|
||||
* exponential backoff — a fetch that rejects (network-level, DNS/TCP/reset)
|
||||
* retries on ANY method; a 5xx retries on GET only, so read polls survive
|
||||
* while write endpoints keep failing closed instead of risking duplicated
|
||||
* side effects. 4xx never retries. Defaults: 3 retries, base 300 ms ×2,
|
||||
* ±20% jitter.
|
||||
*
|
||||
* Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈
|
||||
* OPEN|CLOSED, `pr.draft`, `pr.nodeId` (GraphQL auto-merge), branch
|
||||
* protection normalized for drift comparison.
|
||||
*/
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.github.com'
|
||||
const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
|
||||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||||
const USER_AGENT = 'teqo-agent-scripts/1.0 (github)'
|
||||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||||
|
||||
/**
|
||||
* @typedef {object} GithubApiOptions
|
||||
* @property {string} [base]
|
||||
* @property {string} [graphqlBase]
|
||||
* @property {string} [token]
|
||||
* @property {string} [repository]
|
||||
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
|
||||
* @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3.
|
||||
* @property {number} [backoffMs] Base exponential delay. Default 300.
|
||||
* @property {boolean} [jitter] ±20% randomization on each delay. Default true.
|
||||
* @property {(ms: number) => Promise<void>} [sleepImpl] Test seam for the backoff delay.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {GithubApiOptions} [options]
|
||||
*/
|
||||
export const createApi = ({
|
||||
base,
|
||||
graphqlBase,
|
||||
token,
|
||||
repository,
|
||||
fetchImpl,
|
||||
retries = 3,
|
||||
backoffMs = 300,
|
||||
jitter = true,
|
||||
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
} = {}) => {
|
||||
const baseUrl = (base ?? process.env.GITHUB_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
||||
const graphqlUrl = (graphqlBase ?? process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
)
|
||||
const repo = repository ?? process.env.GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY
|
||||
const fetcher = fetchImpl ?? fetch
|
||||
const [owner, name] = repo.split('/')
|
||||
if (retries < 0 || backoffMs < 0) {
|
||||
throw new Error(
|
||||
`Retry inválido: retries=${retries}, backoffMs=${backoffMs} — ambos devem ser >= 0`,
|
||||
)
|
||||
}
|
||||
const attemptCount = retries + 1
|
||||
|
||||
const backoffDelay = (attempt) => {
|
||||
const base = backoffMs * 2 ** (attempt - 1)
|
||||
return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base
|
||||
}
|
||||
|
||||
const warnRetry = (path, method, reason, attempt, delay) =>
|
||||
console.warn(
|
||||
`[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
|
||||
)
|
||||
|
||||
const headers = (withJson = true) => {
|
||||
const authToken = token ?? process.env.GITHUB_TOKEN
|
||||
if (!authToken) {
|
||||
throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
...(withJson ? { 'Content-Type': 'application/json' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (path, { method = 'GET', body, query } = {}) => {
|
||||
const qs = query
|
||||
? '?' +
|
||||
Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
: ''
|
||||
const url = `${baseUrl}${path}${qs}`
|
||||
const retryAfter = async (attempt, reason) => {
|
||||
const delay = backoffDelay(attempt)
|
||||
warnRetry(path, method, reason, attempt, delay)
|
||||
await sleepImpl(delay)
|
||||
}
|
||||
for (let attempt = 1; attempt <= attemptCount; attempt += 1) {
|
||||
const isLastAttempt = attempt === attemptCount
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
method,
|
||||
headers: headers(),
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isLastAttempt) throw error
|
||||
await retryAfter(attempt, error.message)
|
||||
continue
|
||||
}
|
||||
if (method === 'GET' && !isLastAttempt && RETRYABLE_STATUSES.has(response.status)) {
|
||||
await response.body?.cancel()
|
||||
await retryAfter(attempt, `HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
let text
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
if (isLastAttempt || method !== 'GET') throw error
|
||||
await retryAfter(attempt, error.message)
|
||||
continue
|
||||
}
|
||||
if (response.status === 404 && method === 'GET') return null
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`GitHub API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphql = async (query, variables) => {
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(graphqlUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`GitHub GraphQL falhou (rede): ${error.message}`)
|
||||
}
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub GraphQL → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
const parsed = JSON.parse(text)
|
||||
const firstError = parsed?.errors?.[0]
|
||||
if (firstError) {
|
||||
throw new Error(`GitHub GraphQL: ${firstError.message}`)
|
||||
}
|
||||
return parsed.data ?? null
|
||||
}
|
||||
|
||||
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
|
||||
|
||||
const normalizePullRequest = (pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? '',
|
||||
state: normalizeState(pr.state),
|
||||
merged: Boolean(pr.merged_at || pr.merged),
|
||||
draft: Boolean(pr.draft),
|
||||
mergeable: pr.mergeable ?? null,
|
||||
nodeId: pr.node_id ?? '',
|
||||
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
|
||||
base: { ref: pr.base?.ref ?? '' },
|
||||
})
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/pulls/{number} — normalized.
|
||||
* @param {number} number
|
||||
*/
|
||||
getPullRequest: async (number) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
|
||||
return pr ? normalizePullRequest(pr) : null
|
||||
},
|
||||
|
||||
/** PATCH draft:false — marks a draft PR ready for review. */
|
||||
markPullRequestReady: (number) =>
|
||||
request(`/repos/${owner}/${name}/pulls/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: { draft: false },
|
||||
}),
|
||||
|
||||
/** POST /pulls — creates a PR (never draft). */
|
||||
createPullRequest: async ({ head, base = 'main', title, body }) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls`, {
|
||||
method: 'POST',
|
||||
body: { head, base, title, body },
|
||||
})
|
||||
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Enables GitHub's native auto-merge (GraphQL) with the repo's canonical
|
||||
* merge style (rebase). The server waits for the required checks — a PR
|
||||
* with a red `CI (PR) / checks` can never auto-merge.
|
||||
*/
|
||||
enableAutoMerge: async (nodeId, mergeMethod = 'REBASE') => {
|
||||
await graphql(
|
||||
`
|
||||
mutation EnablePullRequestAutoMerge($id: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $mergeMethod }) {
|
||||
pullRequest {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: nodeId, mergeMethod },
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for
|
||||
* drift comparison; `null` when the branch has no protection rule (404).
|
||||
*/
|
||||
getBranchProtection: async (branch = 'main') => {
|
||||
const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`)
|
||||
if (!rule) return null
|
||||
return {
|
||||
required_status_checks: rule.required_status_checks
|
||||
? {
|
||||
strict: Boolean(rule.required_status_checks.strict),
|
||||
contexts: (rule.required_status_checks.checks ?? []).map((entry) => entry.context),
|
||||
}
|
||||
: null,
|
||||
enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins),
|
||||
required_pull_request_reviews: rule.required_pull_request_reviews ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /repos/{owner}/{repo}/branches/{branch}/protection — replaces the
|
||||
* whole rule (GitHub replace semantics: omitted protections are removed).
|
||||
*/
|
||||
updateBranchProtection: (payload, branch = 'main') =>
|
||||
request(`/repos/${owner}/${name}/branches/${branch}/protection`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
}),
|
||||
|
||||
/** PATCH /repos/{owner}/{repo} — repo settings (e.g. allow_auto_merge). */
|
||||
updateRepository: (payload) =>
|
||||
request(`/repos/${owner}/${name}`, { method: 'PATCH', body: payload }),
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
71
scripts/lib/github-branch-protection.mjs
Normal file
71
scripts/lib/github-branch-protection.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Pure decision for the GitHub `main` branch protection rule (OPS71).
|
||||
*
|
||||
* The desired rule is the repo contract: main requires the `CI (PR) / checks`
|
||||
* check-run (the ci-pr single-job cascade — OPS62) as the only status check
|
||||
* (strict=false, 0 reviews) and `enforce_admins: true` so even repo admins
|
||||
* cannot merge with a red required check (the rule-based protection of the
|
||||
* Forgejo era applied to everyone — preserved).
|
||||
*
|
||||
* The auto-merge safety net never merges on red CI by construction (GitHub's
|
||||
* server-side guarantee), but the branch protection rule is the final defense
|
||||
* against every other merge path (manual API merges included).
|
||||
*/
|
||||
|
||||
export const REQUIRED_CHECK_CONTEXT = 'CI (PR) / checks'
|
||||
|
||||
/**
|
||||
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
|
||||
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
|
||||
* rejects the two together (422 anyOf). The GET normalization reads the
|
||||
* check-run contexts from `checks`, so drift comparison is unaffected.
|
||||
*/
|
||||
export const DESIRED_RULE = Object.freeze({
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
|
||||
},
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: false,
|
||||
block_creations: false,
|
||||
})
|
||||
|
||||
const sameStrings = (have, want) =>
|
||||
have.length === want.length &&
|
||||
want.every((value) => have.includes(value)) &&
|
||||
have.every((value) => want.includes(value))
|
||||
|
||||
/**
|
||||
* True when the existing rule (from `getBranchProtection`, already
|
||||
* normalized) matches the desired rule on every compared field — array
|
||||
* fields as sets.
|
||||
*/
|
||||
export const ruleMatches = (existing) => {
|
||||
if (!existing) return false
|
||||
const wantStatus = DESIRED_RULE.required_status_checks
|
||||
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
|
||||
if (existing.required_pull_request_reviews !== null) return false
|
||||
const haveStatus = existing.required_status_checks
|
||||
if (!haveStatus) return false
|
||||
if (haveStatus.strict !== wantStatus.strict) return false
|
||||
return sameStrings(
|
||||
haveStatus.contexts,
|
||||
wantStatus.checks.map((entry) => entry.context),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with the rule: `create` (no rule), `update` (rule exists with
|
||||
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
|
||||
*
|
||||
* @param {object|null} existing - normalized rule from the API, or null
|
||||
* @returns {{ action: 'create' | 'update' | 'noop' }}
|
||||
*/
|
||||
export const planBranchProtectionRule = (existing) => {
|
||||
if (ruleMatches(existing)) return { action: 'noop' }
|
||||
return { action: existing ? 'update' : 'create' }
|
||||
}
|
||||
31
scripts/lib/github-pr-flow.mjs
Normal file
31
scripts/lib/github-pr-flow.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Pure decision logic for the GitHub PR auto-merge safety net
|
||||
* (scripts/github-pr-automerge.mjs). Unit-tested in
|
||||
* tests/unit/githubPrFlow.unit.spec.ts — the OPS57 (draft veto) / OPS64
|
||||
* (never merge on red CI) pins ported to the GitHub mechanism.
|
||||
*
|
||||
* GitHub's native auto-merge only fires with the required checks green, so
|
||||
* the safety net's only real decisions are: skip (non-main base, draft veto,
|
||||
* already merged) or mark-ready (cursor/* draft) before arming auto-merge.
|
||||
*/
|
||||
|
||||
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)
|
||||
|
||||
/**
|
||||
* @param {object|null} pr - normalized PR from github-api (or null)
|
||||
* @returns {{ action: 'skip' | 'mark-ready' | 'enable-auto-merge', reason: string }}
|
||||
*/
|
||||
export const decideAutomergeAction = (pr) => {
|
||||
if (!pr) return { action: 'skip', reason: 'pr-inexistente' }
|
||||
if (pr.merged) return { action: 'skip', reason: 'ja-mergeada' }
|
||||
if (pr.state !== 'OPEN') return { action: 'skip', reason: 'pr-nao-aberta' }
|
||||
if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' }
|
||||
if (pr.draft) {
|
||||
if (isCursorHead(pr.head?.ref)) return { action: 'mark-ready', reason: 'draft-cursor' }
|
||||
return { action: 'skip', reason: 'draft-veto' }
|
||||
}
|
||||
return { action: 'enable-auto-merge', reason: 'ready' }
|
||||
}
|
||||
228
tests/unit/githubApi.unit.spec.ts
Normal file
228
tests/unit/githubApi.unit.spec.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createApi } from '../../scripts/lib/github-api.mjs'
|
||||
|
||||
type FetchCall = {
|
||||
url: string
|
||||
method?: string
|
||||
init?: { headers?: Record<string, string>; method?: string; body?: string }
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
const ok = (body: unknown, status = 200) =>
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
describe('github-api', () => {
|
||||
it('builds the request with bearer token, JSON body and explicit User-Agent', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
repository: 'fsolla/teqo',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init })
|
||||
return ok({ number: 1, title: 't', body: '', state: 'open', node_id: 'PR_x' })
|
||||
},
|
||||
})
|
||||
|
||||
await api.getPullRequest(1)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/pulls/1')
|
||||
expect(calls[0].init!.headers!.Authorization).toBe('Bearer tok')
|
||||
expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts')
|
||||
})
|
||||
|
||||
it('fails closed when no token is available', async () => {
|
||||
delete process.env.GITHUB_TOKEN
|
||||
const api = createApi({ fetchImpl: async () => ok('{}') })
|
||||
|
||||
await expect(api.getPullRequest(1)).rejects.toThrow(/GITHUB_TOKEN/)
|
||||
})
|
||||
|
||||
it('normalizes the pull request to the CLI contract (state/draft/nodeId/head/base)', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () =>
|
||||
ok({
|
||||
number: 7,
|
||||
title: 't',
|
||||
body: 'Closes #1',
|
||||
state: 'closed',
|
||||
merged_at: '2026-08-19T00:00:00Z',
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
node_id: 'PR_abc123',
|
||||
head: { ref: 'OPS71-x', sha: 'deadbeef' },
|
||||
base: { ref: 'main' },
|
||||
}),
|
||||
})
|
||||
|
||||
const pr = await api.getPullRequest(7)
|
||||
|
||||
expect(pr).toEqual({
|
||||
number: 7,
|
||||
title: 't',
|
||||
body: 'Closes #1',
|
||||
state: 'CLOSED',
|
||||
merged: true,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
nodeId: 'PR_abc123',
|
||||
head: { ref: 'OPS71-x', sha: 'deadbeef' },
|
||||
base: { ref: 'main' },
|
||||
})
|
||||
})
|
||||
|
||||
it('markPullRequestReady patches draft:false', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.markPullRequestReady(4)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
url: 'https://api.github.com/repos/fsolla/teqo/pulls/4',
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ draft: false }),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('createPullRequest posts a Ready PR (never draft) with the given body', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
||||
return ok({ number: 9, title: 't', html_url: 'https://github.com/fsolla/teqo/pull/9' })
|
||||
},
|
||||
})
|
||||
|
||||
const created = await api.createPullRequest({
|
||||
head: 'OPS71-x',
|
||||
base: 'main',
|
||||
title: 'OPS71 — x',
|
||||
body: 'Closes #97',
|
||||
})
|
||||
|
||||
expect(created).toEqual({
|
||||
number: 9,
|
||||
title: 't',
|
||||
htmlUrl: 'https://github.com/fsolla/teqo/pull/9',
|
||||
})
|
||||
expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/pulls')
|
||||
expect(JSON.parse(String(calls[0].body))).toEqual({
|
||||
head: 'OPS71-x',
|
||||
base: 'main',
|
||||
title: 'OPS71 — x',
|
||||
body: 'Closes #97',
|
||||
})
|
||||
})
|
||||
|
||||
it('enableAutoMerge posts the GraphQL mutation with rebase', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), body: init?.body })
|
||||
return ok({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 4 } } } })
|
||||
},
|
||||
})
|
||||
|
||||
await api.enableAutoMerge('PR_abc123')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://api.github.com/graphql')
|
||||
const { query, variables } = JSON.parse(String(calls[0].body))
|
||||
expect(query).toContain('enablePullRequestAutoMerge')
|
||||
expect(query).toContain('mergeMethod')
|
||||
expect(variables).toEqual({ id: 'PR_abc123', mergeMethod: 'REBASE' })
|
||||
})
|
||||
|
||||
it('enableAutoMerge throws with the GraphQL error message', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () =>
|
||||
ok({
|
||||
data: null,
|
||||
errors: [{ message: 'auto-merge is disabled on this repository' }],
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(api.enableAutoMerge('PR_x')).rejects.toThrow(/auto-merge is disabled/)
|
||||
})
|
||||
|
||||
it('getBranchProtection returns null on 404 and normalizes an existing rule', async () => {
|
||||
let calls = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return ok('{"message":"Not Found"}', 404)
|
||||
return ok({
|
||||
url: 'https://api.github.com/repos/fsolla/teqo/branches/main/protection',
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
checks: [{ context: 'CI (PR) / checks', app_id: null }],
|
||||
contexts: ['CI (PR) / checks'],
|
||||
},
|
||||
enforce_admins: { enabled: true, url: '…' },
|
||||
required_pull_request_reviews: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(await api.getBranchProtection('main')).toBeNull()
|
||||
|
||||
const rule = await api.getBranchProtection('main')
|
||||
expect(rule).toEqual({
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
contexts: ['CI (PR) / checks'],
|
||||
},
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('updateBranchProtection PUTs the full desired rule', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method })
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.updateBranchProtection({ enforce_admins: true }, 'main')
|
||||
|
||||
expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/branches/main/protection')
|
||||
expect(calls[0].method).toBe('PUT')
|
||||
})
|
||||
|
||||
it('throws with the API error body on non-2xx', async () => {
|
||||
let calls = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
retries: 0,
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
return ok('{"message":"Bad credentials"}', 401)
|
||||
},
|
||||
})
|
||||
|
||||
await expect(api.getPullRequest(1)).rejects.toThrow(/401/)
|
||||
await expect(api.getPullRequest(1)).rejects.toThrow(/Bad credentials/)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
88
tests/unit/githubBranchProtection.unit.spec.ts
Normal file
88
tests/unit/githubBranchProtection.unit.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DESIRED_RULE,
|
||||
REQUIRED_CHECK_CONTEXT,
|
||||
planBranchProtectionRule,
|
||||
ruleMatches,
|
||||
} from '../../scripts/lib/github-branch-protection.mjs'
|
||||
|
||||
const compliant = {
|
||||
required_status_checks: { strict: false, contexts: [REQUIRED_CHECK_CONTEXT] },
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
}
|
||||
|
||||
describe('github branch-protection rule planning (OPS71)', () => {
|
||||
it('plans create when no rule exists', () => {
|
||||
expect(planBranchProtectionRule(null).action).toBe('create')
|
||||
})
|
||||
|
||||
it('plans noop when the rule already matches', () => {
|
||||
expect(planBranchProtectionRule(compliant).action).toBe('noop')
|
||||
expect(ruleMatches(compliant)).toBe(true)
|
||||
})
|
||||
|
||||
it('plans update when enforce_admins is off (admin bypass = red-CI merge path)', () => {
|
||||
const drift = { ...compliant, enforce_admins: false }
|
||||
expect(planBranchProtectionRule(drift).action).toBe('update')
|
||||
expect(ruleMatches(drift)).toBe(false)
|
||||
})
|
||||
|
||||
it('plans update when the required check context drifts', () => {
|
||||
const drift = {
|
||||
...compliant,
|
||||
required_status_checks: { strict: false, contexts: ['CI (PR) / checks (pull_request)'] },
|
||||
}
|
||||
expect(planBranchProtectionRule(drift).action).toBe('update')
|
||||
})
|
||||
|
||||
it('plans update when a second check is required', () => {
|
||||
const extra = {
|
||||
...compliant,
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
contexts: [REQUIRED_CHECK_CONTEXT, 'migration-lock'],
|
||||
},
|
||||
}
|
||||
expect(planBranchProtectionRule(extra).action).toBe('update')
|
||||
})
|
||||
|
||||
it('plans update when strict becomes true', () => {
|
||||
const drift = {
|
||||
...compliant,
|
||||
required_status_checks: { strict: true, contexts: [REQUIRED_CHECK_CONTEXT] },
|
||||
}
|
||||
expect(planBranchProtectionRule(drift).action).toBe('update')
|
||||
})
|
||||
|
||||
it('plans update when pull request reviews are required (blocks auto-merge)', () => {
|
||||
const drift = {
|
||||
...compliant,
|
||||
required_pull_request_reviews: { required_approving_review_count: 1 },
|
||||
}
|
||||
expect(planBranchProtectionRule(drift).action).toBe('update')
|
||||
})
|
||||
|
||||
it('plans update when status checks are missing entirely', () => {
|
||||
const drift = { ...compliant, required_status_checks: null }
|
||||
expect(planBranchProtectionRule(drift).action).toBe('update')
|
||||
})
|
||||
|
||||
it('compares contexts as a set (order does not matter)', () => {
|
||||
const existing = {
|
||||
...compliant,
|
||||
required_status_checks: { strict: false, contexts: [REQUIRED_CHECK_CONTEXT] },
|
||||
}
|
||||
expect(ruleMatches(existing)).toBe(true)
|
||||
})
|
||||
|
||||
it('the desired rule names exactly the check-run a single `checks` job posts', () => {
|
||||
// GitHub check-run naming: <workflow name> / <job name>. The ci-pr.yml
|
||||
// workflow is named `CI (PR)` and its single job is `checks` (OPS62) —
|
||||
// the required check literal must match that exact check-run.
|
||||
const check = DESIRED_RULE.required_status_checks.checks[0]
|
||||
expect(check.context).toBe('CI (PR) / checks')
|
||||
expect(DESIRED_RULE.required_status_checks.checks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
78
tests/unit/githubPrFlow.unit.spec.ts
Normal file
78
tests/unit/githubPrFlow.unit.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CURSOR_HEAD_PREFIX,
|
||||
decideAutomergeAction,
|
||||
isCursorHead,
|
||||
} from '../../scripts/lib/github-pr-flow.mjs'
|
||||
|
||||
const pr = (overrides: Record<string, unknown> = {}) => ({
|
||||
number: 4,
|
||||
title: 't',
|
||||
body: '',
|
||||
state: 'OPEN',
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
nodeId: 'PR_x',
|
||||
head: { ref: 'OPS71-x', sha: 'abc' },
|
||||
base: { ref: 'main' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('github-pr-flow automerge decision (OPS71)', () => {
|
||||
it('skips a null PR', () => {
|
||||
expect(decideAutomergeAction(null).action).toBe('skip')
|
||||
})
|
||||
|
||||
it('skips an already merged PR (idempotent)', () => {
|
||||
expect(decideAutomergeAction(pr({ merged: true, state: 'CLOSED' })).action).toBe('skip')
|
||||
})
|
||||
|
||||
it('skips a closed PR without merge', () => {
|
||||
expect(decideAutomergeAction(pr({ state: 'CLOSED' })).action).toBe('skip')
|
||||
})
|
||||
|
||||
it('skips a PR whose base is not main', () => {
|
||||
expect(decideAutomergeAction(pr({ base: { ref: 'stage' } })).action).toBe('skip')
|
||||
})
|
||||
|
||||
it('enables auto-merge on a ready PR to main', () => {
|
||||
const verdict = decideAutomergeAction(pr())
|
||||
expect(verdict.action).toBe('enable-auto-merge')
|
||||
expect(verdict.reason).toBe('ready')
|
||||
})
|
||||
|
||||
it('marks a cursor/* draft ready (OPS57 pool flow)', () => {
|
||||
const verdict = decideAutomergeAction(
|
||||
pr({ draft: true, head: { ref: `${CURSOR_HEAD_PREFIX}abc-123`, sha: 'abc' } }),
|
||||
)
|
||||
expect(verdict.action).toBe('mark-ready')
|
||||
})
|
||||
|
||||
it('skips any non-cursor/* draft — the actor veto (OPS57)', () => {
|
||||
const verdict = decideAutomergeAction(
|
||||
pr({ draft: true, head: { ref: 'plans/ops71-x', sha: 'abc' } }),
|
||||
)
|
||||
expect(verdict.action).toBe('skip')
|
||||
expect(verdict.reason).toBe('draft-veto')
|
||||
})
|
||||
|
||||
it('isCursorHead only matches the cursor/ prefix', () => {
|
||||
expect(isCursorHead('cursor/abc-123')).toBe(true)
|
||||
expect(isCursorHead('OPS71-x')).toBe(false)
|
||||
expect(isCursorHead('')).toBe(false)
|
||||
expect(isCursorHead(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('the merge contract has no poll path: ready always arms native auto-merge', () => {
|
||||
// OPS64 pin, GitHub shape: the Forgejo rollup-lie bug is structural here
|
||||
// (single job → one honest check-run), and the server only merges with
|
||||
// required checks green — so the decision surface is exactly this.
|
||||
const actions = [
|
||||
decideAutomergeAction(pr()).action,
|
||||
decideAutomergeAction(pr({ draft: true, head: { ref: 'cursor/x', sha: 'a' } })).action,
|
||||
]
|
||||
expect(actions).toEqual(['enable-auto-merge', 'mark-ready'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue