From 8d4a944127c9eda1b511c07b3610311cf88110e2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 23:03:15 -0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(ci):=20CI/PR=20no=20GitHub=20Actions?= =?UTF-8?q?=20=E2=80=94=20tracker=20de=20Issues=20permanece=20no=20Forgejo?= =?UTF-8?q?=20(Closes=20#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows: ci.yml (job único checks hosted), deploy.yml (verify hosted full → deploy self-hosted no homeserver), flips pós-merge via API (FORGEJO_API_TOKEN/PR_BODY, fail-loud), auto-merge nativo (GraphQL) - scripts: github-api/pr-flow/branch-protection (plain Node zero-dep), pr.mjs reescrito (GitHub + auto-merge), configure-branch-protection, deploy-iara.sh (local, sem SSH, idempotente), forgejo-issue-transition com PR_BODY + fail-loud (I10 absorvida) - AGENTS.md/runbook: seção CI GitHub, branch protection REAL em main, passos manuais do cutover --- .forgejo/worktree.env | 1 + .../workflows/agent-pr-ready-automerge.yml | 50 ++++ .github/workflows/ci.yml | 57 ++++ .github/workflows/deploy.yml | 68 +++++ .../workflows/issue-done-on-main-merge.yml | 43 +++ .../plan-issue-ready-on-main-merge.yml | 43 +++ AGENTS.md | 52 +++- docs/ops/deploy-github-runbook.md | 83 ++++++ ...ara-github-actions-tracker-forgejo-impl.md | 117 ++++++++ opencode.jsonc | 4 +- package.json | 5 +- scripts/configure-branch-protection.mjs | 77 +++++ scripts/deploy-iara.sh | 97 +++++++ scripts/forgejo-issue-transition.mjs | 37 ++- scripts/github-pr-automerge.mjs | 63 +++++ scripts/lib/github-api.mjs | 267 ++++++++++++++++++ scripts/lib/github-branch-protection.mjs | 74 +++++ scripts/lib/github-pr-flow.mjs | 28 ++ scripts/lib/load-project-env.mjs | 7 + scripts/pr.mjs | 72 +++-- tests/unit/github-api.test.ts | 116 ++++++++ tests/unit/github-branch-protection.test.ts | 66 +++++ tests/unit/github-pr-flow.test.ts | 54 ++++ tsconfig.test.json | 1 + 24 files changed, 1433 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/agent-pr-ready-automerge.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/issue-done-on-main-merge.yml create mode 100644 .github/workflows/plan-issue-ready-on-main-merge.yml create mode 100644 docs/ops/deploy-github-runbook.md create mode 100644 docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo-impl.md create mode 100644 scripts/configure-branch-protection.mjs create mode 100644 scripts/deploy-iara.sh create mode 100644 scripts/github-pr-automerge.mjs create mode 100644 scripts/lib/github-api.mjs create mode 100644 scripts/lib/github-branch-protection.mjs create mode 100644 scripts/lib/github-pr-flow.mjs create mode 100644 tests/unit/github-api.test.ts create mode 100644 tests/unit/github-branch-protection.test.ts create mode 100644 tests/unit/github-pr-flow.test.ts diff --git a/.forgejo/worktree.env b/.forgejo/worktree.env index e3bf0f4..6de39ab 100644 --- a/.forgejo/worktree.env +++ b/.forgejo/worktree.env @@ -1,2 +1,3 @@ FORGEJO_REPOSITORY=amana/iara-pwa +GITHUB_REPOSITORY=fsolla/iara-pwa WORKTREES_ROOT=~/.worktrees/iara diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml new file mode 100644 index 0000000..5a4ad1a --- /dev/null +++ b/.github/workflows/agent-pr-ready-automerge.yml @@ -0,0 +1,50 @@ +# Safety net (OPS1 — GitHub): todo PR same-repo para main nasce com o +# auto-merge ARMADO (rebase — estilo canônico do repo). Cobre PRs humanos e de +# agente (as branches de entrega da Iara são `-` para os dois — +# não há distinção por prefixo), e PRs que esqueceram o `--automerge` local. +# Draft é o veto do ator: nunca é marcado Ready (a Iara não cria draft por +# nenhum fluxo) — o job skips (exit 0). +# +# O auto-merge NATIVO do GitHub substitui o poll CLI da era Forgejo +# (forgejo-pr-automerge.mjs — dormente até a Fase 2): +# `enablePullRequestAutoMerge` (GraphQL, REBASE) é server-side — o GitHub só +# mergeia quando todo required check (o check-run `checks` do ci.yml) está +# verde e o PR é mergeável. Nada a pollar, nada a confiar localmente. +# +# Permissions: contents + pull-requests write — o suficiente para a mutation +# GraphQL com o GITHUB_TOKEN built-in. + +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. Tem que ficar no `if:` (pré-checkout): para um fork PR, + # o actions/checkout puxa o head do fork, então uma guarda interna ao CLI + # já estaria rodando o script do próprio fork — RCE com o token do Actions. + 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 é plain Node (stdlib) — sem bun. Disable package-manager-cache. + - uses: actions/setup-node@v5 + with: + node-version: 24 + package-manager-cache: false + - name: Arm auto-merge (rebase) + env: + # `github.token` explícito: o env GITHUB_TOKEN não é garantido em + # toda via de evento; a forma robusta é o contexto documentado. + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd0bb4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +# PR gate (OPS1 — GitHub Actions): feature → main roda `checks` — um job único +# sequencial (lint → build → unit → e2e) em runner hosted. O merge é gated pelo +# required check `checks` da proteção de `main` + auto-merge nativo do GitHub +# (o servidor só mergeia com o required check verde — sem branch protection +# server-side na era Forgejo, aqui a regra é real e vale até para admins). +# +# SEM verificador de `main` (decidido no gate): publicar é ato manual via +# `workflow_dispatch` (deploy.yml), que roda a suíte full antes de tocar o +# homeserver. +# +# Iara é um SPA — sem services/Postgres. O job id `checks` preserva o check-run +# `CI / checks` que o required check e o auto-merge usam (literal de match na +# proteção: `checks` — nome do check-run/job, não o display). +# +# Repo público → fork PR não queima minutes hosted: `if:` same-repo no job +# (mesma classe de guarda de RCE dos outros workflows). + +name: CI + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-pr-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + checks: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + + - name: Instalar dependências + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Build (tsc + vite) + run: bun run build + + - name: Testes unitários (bun test) + run: bun run test:unit + + - name: Instalar navegador do Playwright + run: bunx playwright install --with-deps chromium + + - name: E2E (Playwright — só aqui, nunca local) + run: bun run test:e2e diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..314f4f2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,68 @@ +# Deploy de produção manual (OPS1 — GitHub Actions). `workflow_dispatch` +# only: NÃO existe verificador automático de `main` (o ci.yml não roda em +# push) — publicar é um ato deliberado do operador, e este workflow roda a +# suíte FULL (sem skips, e2e incluída) antes do job de deploy tocar o +# homeserver. +# +# DOIS jobs, mesmo workflow: +# verify — ubuntu-latest (hosted): cascata full. +# deploy — needs: [verify], runs-on [self-hosted, homeserver]: o runner +# self-hosted do GitHub NO homeserver executa +# scripts/deploy-iara.sh LOCALMENTE (sem SSH; runner outbound-only, +# funciona atrás do túnel Cloudflare). O job hosted nunca alcança +# o homeserver; o job self-hosted nunca conta minutes hosted. +# +# O deploy é idempotente (guard "already deployed" via marker do SHA no +# deploy-iara.sh) e serializado por flock no homeserver. O `deploy` busca o +# repo de github.com (público); o HEAD guard só deploya quando o SHA +# disparado é o HEAD de main (dispatch defasado → skip). + +name: Deploy (manual) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + + - name: Instalar dependências + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Build (tsc + vite) + run: bun run build + + - name: Testes unitários (bun test) + run: bun run test:unit + + - name: Instalar navegador do Playwright + run: bunx playwright install --with-deps chromium + + - name: E2E (Playwright) + run: bun run test:e2e + + deploy: + name: deploy no homeserver + # Roda no runner self-hosted do GitHub NO homeserver (labels self-hosted + + # homeserver): o script executa localmente — sem SSH hop, sem workstation. + # Deliberadamente ligado SÓ aqui (dispatch manual), nunca no ci.yml: um + # job self-hosted é execução de código na máquina de produção. + needs: [verify] + if: needs.verify.result == 'success' + runs-on: [self-hosted, homeserver] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - name: Deploy do dist no homeserver + run: bash scripts/deploy-iara.sh "$GITHUB_SHA" diff --git a/.github/workflows/issue-done-on-main-merge.yml b/.github/workflows/issue-done-on-main-merge.yml new file mode 100644 index 0000000..5b07cbe --- /dev/null +++ b/.github/workflows/issue-done-on-main-merge.yml @@ -0,0 +1,43 @@ +# Main merge flips claimed issues to done + in-prod. +# OPS1: o PR vive no GitHub; as ISSUES ficam no tracker do Forgejo. O job passa +# o corpo do PR via PR_BODY (evento do GitHub) e +# scripts/forgejo-issue-transition.mjs flipa labels/comentário na API do +# Forgejo com o FORGEJO_API_TOKEN (PAT) — o GITHUB_TOKEN built-in não toca +# Issues do Forgejo. FORGEJO_REPOSITORY explícito: o repo do tracker +# (amana/iara-pwa) difere do GitHub (fsolla/iara-pwa) — sem isso, a lib +# resolveria GITHUB_REPOSITORY e o flip 404a no repo errado. Fail-loud: um +# flip que falhe deixa o job vermelho (I10 absorvida). +# Guarda de fork: num PR de fork mergeado, o checkout puxaria o script do +# próprio fork, que rodaria com o PAT do repo (RCE) — same-repo no `if:`. + +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 + timeout-minutes: 5 + env: + FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }} + # Explícito: em Actions, GITHUB_SERVER_URL aponta github.com e + # forgejo-api.mjs resolveria a base errada (410). + FORGEJO_API_URL: https://git.solla.dev/api/v1 + FORGEJO_REPOSITORY: amana/iara-pwa + PR_BODY: ${{ github.event.pull_request.body }} + steps: + - uses: actions/checkout@v5 + # Script é plain Node (stdlib) — sem bun. Disable package-manager-cache: + # setup-node@v5 olha packageManager no package.json antes do 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 }}" diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.github/workflows/plan-issue-ready-on-main-merge.yml new file mode 100644 index 0000000..2893d62 --- /dev/null +++ b/.github/workflows/plan-issue-ready-on-main-merge.yml @@ -0,0 +1,43 @@ +# Safety net: PRs mergeados em main com `Related #N` promovem Issues que ainda +# aguardam o plano de intenção (`blocked` + docs/plans/ link) para `ready`. +# Idempotente com `bun run agent:ready` (caminho feliz do agente). OPS1: o PR +# vive no GitHub, as Issues no tracker do Forgejo — o corpo do PR vem via +# PR_BODY (evento do GitHub) e scripts/agent-promote-related-on-merge.mjs +# flipa labels na API do Forgejo (FORGEJO_API_TOKEN; FORGEJO_REPOSITORY +# explícito — repo do tracker difere do GitHub). Guarda de fork (RCE) no `if:`. + +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 }} + FORGEJO_API_URL: https://git.solla.dev/api/v1 + FORGEJO_REPOSITORY: amana/iara-pwa + PR_BODY: ${{ github.event.pull_request.body }} + steps: + - uses: actions/checkout@v5 + - 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 }}" diff --git a/AGENTS.md b/AGENTS.md index 5cc7b14..8f0950b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ Ciclo completo (detalhes nas skills `plan-issue` / `work-issue`): 3. **`/work-issue`** → plano de implementação (`docs/plans/-impl.md`), pausa para aprovação humana, execução. 4. **`/simplify`** (skill `code-simplification`) → 3 reviews locais paralelos no diff. 5. **`capture-review-debts`** → triagem + débitos (`bun run agent:register` / `agent:file-miss`). -6. **PR Ready** `Closes #N` com auto-merge (`bun run pr -- --automerge`) → CI (lint+build+e2e) → merge → `done`/`in-prod` → deploy. +6. **PR Ready** `Closes #N` com auto-merge (`bun run pr -- --automerge`) → CI (lint+build+e2e) → merge → `done`/`in-prod` → deploy (manual, `workflow_dispatch`). ### Comandos de agente (bun) @@ -77,24 +77,54 @@ Ciclo completo (detalhes nas skills `plan-issue` / `work-issue`): | `bun run agent:ready` | promove `blocked` → `ready` (plano em main) | | `bun run agent:file-miss` | registra défice do fluxo de agentes | | `bun run agent:status` | grafo/overview read-only | -| `bun run pr -- --head --body "Closes #N" --automerge` | cria PR e mergea quando os checks passarem | +| `bun run pr -- --head --body "Closes #N" --automerge` | cria PR no GitHub (Ready) e arma o auto-merge nativo — o servidor mergea quando `checks` ficar green (requer `GITHUB_TOKEN` env) | +| `bun run configure:branch-protection` | (re)aplica a proteção de `main` no GitHub (required check `checks` + enforce admins; idempotente) | **O comando de terminal `worktree` é o roteador global** (`~/.config/shell/worktree.sh`): descobre o projeto pelo diretório atual (`git rev-parse`) e roda o script do projeto; config por projeto em `.forgejo/worktree.env` (commitado, sem secrets). -## CI (Forgejo Actions — `.forgejo/workflows/`) +## CI (GitHub Actions — `.github/workflows/`) -- `ci.yml` — em push (main) e PR: job `checks` (lint + build + **e2e** Playwright) e job `deploy` (só main, `needs: checks`, rsync para o homeserver). -- `issue-done-on-main-merge.yml` — PRs mergeadas com `Closes #N` flipam a Issue para `done`/`in-prod` (`scripts/forgejo-issue-transition.mjs`). -- `plan-issue-ready-on-main-merge.yml` — PRs com `Related #N` promovem `blocked` → `ready` (`scripts/agent-promote-related-on-merge.mjs`). -- `agent-pr-ready-automerge.yml` — marca Ready e mergea quando `checks` ficar green (safety net do auto-merge). +O **código/PR/CI vive no GitHub** (`github.com/fsolla/iara-pwa`, público; o +`origin` local aponta para ele após o cutover); o **tracker de Issues +permanece no Forgejo** (`git.solla.dev/amana/iara-pwa` — labels, claims, +`bun run issue`/`agent:*` e os flips pós-merge continuam falando com a API do +Forgejo; GitHub é só o host do Actions). -**Sem branch protection server-side em `main`** (espelha o teqo): o gate de merge é o -próprio script de auto-merge (`pr.mjs --automerge` / `forgejo-pr-automerge.mjs`), -que espera os status checks ficarem green antes de mergear; o deploy é gated pelo -`needs: checks` do `ci.yml`. +- `ci.yml` — **PR gate**: job único `checks` em `ubuntu-latest` (lint + build + + unit + **e2e** Playwright); seu check-run `CI / checks` é o required check de + `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do + check-run/job; a UI exibe `CI / checks`). **Sem verificador de `main`** — + publicar é ato manual (deploy.yml). +- `deploy.yml` — **manual (`workflow_dispatch`)**: `verify` (hosted, suíte + **full**) → `deploy` (`needs: [verify]`, runner **self-hosted no + homeserver**) executando `scripts/deploy-iara.sh` localmente (sem SSH; + idempotente via marker do SHA). +- `issue-done-on-main-merge.yml` — PRs mergeadas com `Closes #N` flipam a Issue + para `done`/`in-prod` no Forgejo via API (`scripts/forgejo-issue-transition.mjs`, + fail-loud). +- `plan-issue-ready-on-main-merge.yml` — PRs com `Related #N` promovem `blocked` + → `ready` (`scripts/agent-promote-related-on-merge.mjs`). +- `agent-pr-ready-automerge.yml` — safety net: arma o **auto-merge nativo** do + GitHub (GraphQL `enablePullRequestAutoMerge`, rebase) para todo PR same-repo + não-draft (`scripts/github-pr-automerge.mjs`). O servidor só mergeia com o + required check `checks` verde — nada de poll. + +**Branch protection REAL em `main`** (era Forgejo não tinha): required check +`checks` + `enforce_admins: true` (nem admin mergeia com CI vermelho). +Configurado via `bun run configure:branch-protection` (idempotente). O merge +automático é o nativo do GitHub — a semântica de "cria e mergea quando os +checks passarem" (`bun run pr -- --automerge`) é preservada pela garantia do +servidor (o `--automerge` local arma o auto-merge; sem poll local). + +**Deploy:** manual via `workflow_dispatch` no GitHub (job `Deploy (manual)`), só +após `verify` full green. Nada de deploy automático em push. **e2e só no CI** — nunca rode `bun run test:e2e` local como parte do fluxo normal. +**Arquivos do fluxo Forgejo** (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`) +permanecem **dormentes** como via de rollback até a remoção na entrega +sucessora (Fase 2); o runner do Forgejo fica desligado após o cutover. + ## Regras de entrega - **Gate antes de push:** `bun run gate` limpo. CI roda lint+build+e2e de novo no PR. diff --git a/docs/ops/deploy-github-runbook.md b/docs/ops/deploy-github-runbook.md new file mode 100644 index 0000000..726047c --- /dev/null +++ b/docs/ops/deploy-github-runbook.md @@ -0,0 +1,83 @@ +# Runbook — CI/PR no GitHub, tracker no Forgejo, deploy manual (OPS1) + +O código/PR/CI vive no GitHub (`github.com/fsolla/iara-pwa`, público); as +Issues/labels/claims continuam no Forgejo (`git.solla.dev/amana/iara-pwa`, +via API); o deploy é **manual** (`workflow_dispatch` no GitHub) com o runner +self-hosted **no homeserver** — nada de deploy automático em push e a +workstation não roda mais a suíte de CI. + +## Cutover (uma vez, nesta ordem — passos (a)–(c) ANTES do primeiro push) + +1. **(a) Remotes locais.** `origin` passa a ser o GitHub; o Forgejo fica como + remote secundário congelado (o repo lá para de receber push; tracker + intacto por API). + + ```bash + git remote set-url origin git@github.com:fsolla/iara-pwa.git + git remote add forgejo ssh://git@192.168.15.142:2222/amana/iara-pwa.git 2>/dev/null || true + ``` + + O config de remotes é compartilhado entre os worktrees do repo (`.git/config` + único) — vale para todos. Sem este passo, `bun run gate`/diff comparariam + contra um `origin/main` defasado (Forgejo). + +2. **(b) Secrets e config do repo no GitHub.** + - Secret `FORGEJO_API_TOKEN` no repo (PAT do Forgejo com escopo de repo — + usado pelos flips pós-merge; nunca o `GITHUB_TOKEN`). + - Repo setting **Allow auto-merge** habilitado (Settings → General → + Allow auto-merge) — sem isso o GraphQL `enablePullRequestAutoMerge` + falha e o job do safety net vai vermelho (visível). + - Localmente: `GITHUB_TOKEN` PAT do usuário (escopo `repo`) para + `bun run pr` e `bun run configure:branch-protection`. + +3. **(c) Branch protection de `main`** (required check `checks` + + `enforce_admins: true`): + + ```bash + GITHUB_TOKEN= bun run configure:branch-protection -- --dry-run + GITHUB_TOKEN= bun run configure:branch-protection + ``` + +4. **(d) Desligar o runner do Forgejo** (reversível) — logo após o primeiro PR + GitHub validado: sem ele, nada mais dispara no repo congelado; o runner + volta a disputar a workstation à toa. + +5. **(e) Homeserver: bun + runner self-hosted do GitHub.** + - Instalar `bun` no homeserver (o `scripts/deploy-iara.sh` usa `bun install` + + `bun run build` lá — precisa existir no PATH do runner; `BUN` env + sobrepõe o default). + - Instalar o `actions-runner` com labels `self-hosted, homeserver`. + - Não bloqueia PRs: o deploy é manual e só existe depois do runner. + +6. **(f) Push → PR → CI → merge → flips.** O PR do próprio OPS1 valida o fluxo + ao vivo: CI `checks` no GitHub → auto-merge → flip da Issue no Forgejo. + +## Deploy de produção + +1. `workflow_dispatch` no GitHub (`Deploy (manual)`) — roda `verify` (hosted, + suíte full incl. e2e) e, só se verde, `deploy` no homeserver + (`scripts/deploy-iara.sh `: HEAD guard → flock → marker do SHA → + build → rsync local para `~/iara-pwa/dist/`). +2. Dispatch do mesmo SHA de novo → no-op idempotente (marker). + +## Rollback + +- **CI/PR:** os arquivos do fluxo Forgejo (`.forgejo/workflows/`, + `scripts/forgejo-pr-automerge.mjs`) permanecem **dormentes** no repo até a + Fase 2 (remoção na entrega sucessora). Reverter a este PR restaura o fluxo + GitHub anterior; reverter a Fase 2 restaura o fluxo Forgejo (religar o + runner e apontar `origin` de volta para o Forgejo). +- **Deploy:** o site servido é um diretório estático (`~/iara-pwa/dist/`); o + marker `~/iara-pwa/.deployed-sha` registra o SHA em produção — para reverter, + re-dispatch do SHA anterior (ou rsync manual). + +## Falhas conhecidas + +- **Literal do required check:** a proteção usa `checks` (nome do check-run/ + job), não o display `CI / checks` — pinado no spec unit. Se o PR não mergear + com `mergeable_state: blocked`, re-verifique o literal com + `configure:branch-protection` (drift reporta). +- **Flip com 404:** se `FORGEJO_REPOSITORY`/`FORGEJO_API_URL` sumirem de um + workflow de flip, a lib resolve `GITHUB_REPOSITORY` (repo GitHub) → 404 no + tracker. Os workflows novos setam ambos explicitamente; falha agora sai + vermelha (fail-loud), nunca silenciosa. diff --git a/docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo-impl.md b/docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo-impl.md new file mode 100644 index 0000000..974a0b8 --- /dev/null +++ b/docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo-impl.md @@ -0,0 +1,117 @@ +# Impl: Mover CI/PR do Forgejo Actions para o GitHub Actions — tracker de Issues permanece no Forgejo + +Status: aprovado +Atualizado em: 2026-08-20 +Issue: #17 +Intenção: docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo.md +Appetite restante: herdado (~2–3 dias eng; este impl é a Fase 1 — remoção dos arquivos do Forgejo é entrega sucessora) + +## Leitura da intenção + +- **Outcome:** código/PR/CI no GitHub Actions (`fsolla/iara-pwa` público) com a mesma cascata de verificações (lint → build → unit → e2e) em job único `checks`; tracker de Issues permanece no Forgejo por API (labels, claims, flips); deploy **manual** (`workflow_dispatch`) com `verify` hosted full → `deploy` no runner self-hosted do homeserver (sem SSH); rollup do job `checks` é o required check de `main` e o gate do merge automático — agora no GitHub; `bun run pr` mantém a semântica; runner do Forgejo desligado após o cutover. +- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips — nunca migra); estrutura de verificação de job único preservada (nada de matrix); e2e full no PR preservado; deploy NÃO volta a ser automático; nunca merge com CI vermelho; workstation livre da suíte; repo do Forgejo permanece o principal (GitHub é o espelho de código + superfície de PR/CI). +- **O que reavaliar:** a intenção sugere "API/`gh` equivalente" para `pr.mjs` e "auto-merge nativo ou safety net equivalente". Reavaliado contra o precedente teqo OPS71 (merged 2026-08-19, mesma migração): **`gh` não é usado** (filosofia plain-Node zero-dep do repo); os helpers novos são scripts Node stdlib sobre a REST+GraphQL do GitHub; auto-merge é o **nativo do GitHub** (GraphQL `enablePullRequestAutoMerge`, REBASE — o servidor só mergeia com required checks verdes; nada de poll/semáforo portado). O "sem branch protection server-side" do AGENTS.md morre com a migração: o rollup `checks` vira required check REAL no GitHub (`enforce_admins: true`). + +## Abordagem recomendada + +```mermaid +flowchart LR + W[worktree local] -->|push origin| GH[github.com/fsolla/iara-pwa público] + GH -->|PR Ready base main Closes #N| CI["ci.yml — job único checks hosted (lint→build→unit→e2e full)"] + CI -->|required check checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"] + AM -->|merge| MAIN[main no GitHub] + MAIN -->|PR closed merged + PR_BODY| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN + FORGEJO_REPOSITORY explícitos"] + MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite] + V -->|needs| D["deploy runs-on self-hosted homeserver — scripts/deploy-iara.sh local"] + D --> P[~/iara-pwa/dist no homeserver] +``` + +**Opções consideradas:** + +- **A — Port do teqo OPS71 (mesma migração, merged 2026-08-19):** libs plain-Node (`github-api.mjs`, `github-pr-flow.mjs`, `github-branch-protection.mjs`) + CLIs (`github-pr-automerge.mjs`, `github-pr.mjs`, `configure-branch-protection.mjs`) + 5 workflows `.github/workflows/` + flip scripts com `PR_BODY` + runbook de cutover. Sem `gh`, sem poll. +- **B — `gh` CLI nos scripts/workflows:** binário novo em todas as máquinas + runner, contra a convenção zero-dep do repo — rejeitada (teqo OPS71 rejeitou o mesmo). +- **C — Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único:** reimplementa o semáforo que a intenção manda cortar; o bug do rollup do runner Forgejo (OPS64) não existe no GitHub com job único + required check real — rejeitada. +- **D — Deploy via artifact do hosted → download no homeserver:** plumbing de artifact que o teqo rejeitou; o build no homeserver espelha o teqo (deploy local, sem SSH) — rejeitada. +- **E — Deletar os arquivos do Forgejo já nesta entrega:** queima a via de rollback (repo Forgejo congelado no main pré-cutover mantém workflows vivos); teqo fez Fase 2 separada — rejeitada nesta entrega, vira Issue sucessora. +- **F — Auto-merge só para branch `agent/*` (igual `.forgejo/workflows/agent-pr-ready-automerge.yml`):** as branches de entrega da Iara são `-` para humano e agente igualmente (worktree compartilhado) — não há distinção por prefixo; o safety net arma auto-merge para **todo PR same-repo não-draft** (draft = veto do ator), como no teqo — a opção é descartada pela inexistência do prefixo. +- **G — Manter `--draft` no `bun run pr`:** regra do repo é "PR nunca draft"; teqo tornou a ausência estrutural (sem flag). `--draft` cai, falha loud se passado. + +**Recomendação:** A + variante de F (auto-merge para todo PR same-repo não-draft) + G — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente), o aceite da intenção exige exatamente esse desenho, e o precedente teqo foi validado ao vivo no mesmo mês (required-check literal, auto-merge nativo, flips via API, deploy verify→self-hosted). + +**Rejeitadas:** B, C, D, E, e manter `--draft` (G, refutada). Hipótese da intenção "sem branch protection server-side" — **refinada**: no GitHub a proteção existe e é o gate final (required check `checks`, `enforce_admins: true`); o AGENTS.md é corrigido. + +### Deltas Iara × teqo OPS71 (importantes) + +1. **`FORGEJO_REPOSITORY` explícito nos workflows de flip.** No teqo, repo Forgejo e GitHub têm o mesmo nome (`fsolla/teqo`); na Iara são `amana/iara-pwa` (tracker) vs `fsolla/iara-pwa` (GitHub). Sem a variável, `forgejo-api.mjs` resolveria `GITHUB_REPOSITORY` (setado pelo Actions) → flip no repo errado → 404. Todos os workflows que falam com o tracker setam `FORGEJO_REPOSITORY=amana/iara-pwa` + `FORGEJO_API_URL=https://git.solla.dev/api/v1` + `FORGEJO_API_TOKEN` (o `GITHUB_SERVER_URL` do GitHub resolveria a base errada — mesma armadilha que o teqo documentou). +2. **`bun` no CI, sem serviços.** Iara é SPA: ci.yml sem Postgres/services; `oven-sh/setup-bun@v2`; e2e com `bunx playwright install --with-deps chromium` (hosted ubuntu precisa das deps de sistema; a workstation não tinha). Flip workflows usam `setup-node` (scripts plain-Node, sem install). +3. **Sem convenção `cursor/*`.** Draft = veto em qualquer caso (`github-pr-flow.mjs` sem o caminho mark-ready do teqo — a Iara não cria draft por nenhum fluxo). +4. **`pr.mjs` arma o auto-merge direto.** O `bun run pr -- --automerge` local cria o PR E arma o auto-merge nativo via GraphQL (o PAT do usuário tem direito); o safety net cobre PRs esquecidos. Semântica de "cria e mergea quando os checks passarem" preservada — o "quando" é garantido pelo servidor, não por poll. +5. **Deploy é site estático.** `scripts/deploy-iara.sh`: HEAD guard (`git ls-remote` no GitHub público) → flock → workspace fetch no SHA → marker `~/iara-pwa/.deployed-sha` (idempotência — dispatch duplicado é no-op, no molde do guard do teqo) → `bun install` + `bun run build` → `rsync -a --delete` do dist para `~/iara-pwa/dist/` (mesmo destino do rsync via SSH de hoje, agora local). Sem docker/migrate. +6. **`configure-branch-protection.mjs` com literal `checks`.** Verificado ao vivo no teqo (PR #742): o GitHub casa o required check pelo nome do CHECK-RUN (o job `checks`), não pelo display `CI / checks`. O literal `checks` é pinado no spec unit + verificado no primeiro PR (fail-closed: literal errado = PR não mergea, visível, nunca merge vermelho). + +### Componentes / mudanças + +- **`.github/workflows/ci.yml`** (novo): port do `.forgejo/workflows/ci.yml` job `checks` — `pull_request: branches: [main]`; `runs-on: ubuntu-latest`; `timeout-minutes: 30`; `permissions: contents: read`; `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }`; `if:` same-repo no job (repo público — fork não queima minutes). Steps: checkout → setup-bun → `bun install --frozen-lockfile` → lint → build → test:unit → `bunx playwright install --with-deps chromium` → e2e. Sem job `deploy` (ver deploy.yml) e sem push-main (sem verificador de main — decisão do gate). +- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs, ref main). Job `verify` (ubuntu-latest): suíte **full** sem skips (lint → build → unit → e2e). Job `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): checkout → `bash scripts/deploy-iara.sh "$GITHUB_SHA"`. +- **`.github/workflows/issue-done-on-main-merge.yml`** (novo): `pull_request: types: [closed]`; `if:` merged && base main && **same-repo** (guarda RCE — checkout de fork + PAT = RCE); env `FORGEJO_API_TOKEN` (secret), `FORGEJO_API_URL`, `FORGEJO_REPOSITORY`, `PR_BODY` (do evento); setup-node; `node scripts/forgejo-issue-transition.mjs --pr `. +- **`.github/workflows/plan-issue-ready-on-main-merge.yml`** (novo): idem + `if:` com `Related #` no body (e same-repo). +- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo): `pull_request: [opened, reopened, synchronize, ready_for_review, converted_to_draft]`; `if:` open && same-repo; `permissions: { contents: write, pull-requests: write }`; `GITHUB_TOKEN: ${{ github.token }}` explícito; `node scripts/github-pr-automerge.mjs --pr `. +- **`scripts/lib/github-api.mjs`** (novo, plain Node): REST+GraphQL zero-dep (molde do `forgejo-api.mjs`), auth `GITHUB_TOKEN`, base `GITHUB_API_URL`/default `https://api.github.com`, repo `GITHUB_REPOSITORY`/default `fsolla/iara-pwa`; retry com backoff (política do teqo: 5xx retry só GET; 4xx nunca); shapes normalizados (`pr.state` OPEN|CLOSED, `pr.draft`, `pr.nodeId`); endpoints: `getPullRequest`, `markPullRequestReady`, `createPullRequest` (nunca draft), `enableAutoMerge` (GraphQL REBASE), `getBranchProtection` (normalizado p/ drift), `updateBranchProtection`, `updateRepository`. +- **`scripts/lib/github-pr-flow.mjs`** (novo, puro): `decideAutomergeAction(pr)` → skip (inexistente/merged/não-open/base≠main/**draft-veto**) | enable-auto-merge. Pin OPS57 (draft = veto) e OPS64 (nunca merge vermelho — estrutural no GitHub) no jeito GitHub. Unit-testado. +- **`scripts/github-pr-automerge.mjs`** (novo, plain Node): CLI do workflow — lê PR, `decideAutomergeAction`, arma auto-merge no `node_id`; erro do GraphQL → exit 1 (job vermelho, visível); idempotente. +- **`scripts/pr.mjs`** (reescrito): `bun run pr -- --head [--title ] [--body "…"] [--automerge]` contra a REST do GitHub (GITHUB_TOKEN env; `loadProjectEnv` + `GITHUB_REPOSITORY` do env file). `--automerge` arma auto-merge nativo (GraphQL) após criar. `--draft` removido (falha loud). Sem poll — o servidor mergea quando o required check verde. +- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `REQUIRED_CHECK_CONTEXT = 'checks'` (literal verificado no teqo), `DESIRED_RULE` (strict=false, 0 reviews, `enforce_admins: true`, sem restrictions), `ruleMatches`/`planBranchProtectionRule` (create|update|noop). Unit-testado. +- **`scripts/configure-branch-protection.mjs`** (novo): idempotente (read → plan → apply → verify), `--dry-run`; adiciona `bun run configure:branch-protection`. +- **`scripts/forgejo-issue-transition.mjs`** (editado): aceita `PR_BODY` env (o corpo do PR agora vem do evento GitHub; sem PR_BODY mantém o fallback da API do Forgejo, dormente); **falha loud** (exit 1 se qualquer flip falhar — I10 absorvida: "o flip novo roda no GitHub com token apropriado e falha loud"); comentário do flip atualizado ("deploy manual via workflow_dispatch"). +- **`scripts/agent-promote-related-on-merge.mjs`** (editado): `PR_BODY` já suportado (usado pelos workflows novos; sem PR_BODY mantém a leitura via API do Forgejo). +- **`scripts/deploy-iara.sh`** (novo): ver Opção 5 acima. Env defaults: `IARA_REPO_URL=https://github.com/fsolla/iara-pwa.git`, `WORKSPACE_DIR=$HOME/iara-deploy`, `SERVE_DIR=$HOME/iara-pwa/dist`, `DEPLOY_LOCK=/tmp/iara-deploy.lock`. +- **`scripts/lib/load-project-env.mjs` + `.forgejo/worktree.env`**: `GITHUB_REPOSITORY=fsolla/iara-pwa` no env file (commitado, sem segredos) e carregado no process.env como `FORGEJO_REPOSITORY` (default local p/ os scripts GitHub). +- **`package.json`**: `pr:automerge` → `scripts/github-pr-automerge.mjs`; novo `configure:branch-protection` → `scripts/configure-branch-protection.mjs`. `issue:transition` permanece (mesmo arquivo). +- **`opencode.jsonc`**: comentário do MCP `github` atualizado (fluxo de PR/CI volta a ser GitHub, mas via scripts `bun run pr` — MCP continua desligado; o `forgejo` MCP permanece, é o tracker). +- **`AGENTS.md` + skills (`work-issue`/execution-pipeline)**: seção CI/Workflow reescrita para GitHub (workflows, auto-merge nativo, required check `checks`, deploy manual, branch protection REAL — corrige o invariante "sem branch protection"); comandos `bun run pr`/`worktree` mantidos (semântica preservada); passo do cutover nomeado. +- **`docs/ops/deploy-github-runbook.md`** (novo): runbook do cutover na ordem + rollback + secrets + runners (ver Fases). + +### Migration / Access / UI + +- **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), `pr.mjs` reescrito, `github-pr-automerge.mjs`, `configure-branch-protection.mjs`, `deploy-iara.sh`, flips com PR_BODY, `load-project-env`/env file + specs unit novas (`tests/unit/github-api.test.ts`, `github-pr-flow.test.ts`, `github-branch-protection.test.ts`, molde bun:test do `ws.test.ts`). Gate: `bun run test:unit`. +2. **Workflows** — `.github/workflows/*` (5). Nada do `.forgejo/workflows/` é tocado nesta fase. Gate: `bun run gate` + review YAML. +3. **Docs/skills/runbook** — AGENTS.md, skills, `docs/ops/deploy-github-runbook.md`, opencode.jsonc. +4. **Gates finais + entrega** — `bun run gate`; PR via `bun run pr -- --automerge` (semântica nova) com `Closes #17`; CI verde no GitHub → auto-merge → flips da #17 (validação ao vivo do fluxo no merge do próprio OPS1) → deploy via dispatch manual quando o runner do homeserver estiver pronto. **Passos manuais do cutover listados para o humano** (ordem do teqo OPS71): (a) remotes (`origin` → GitHub; Forgejo secundário); (b) PAT `GITHUB_TOKEN` local + secret `FORGEJO_API_TOKEN` no GitHub + `allow_auto_merge` no repo; (c) `bun run configure:branch-protection`; (d) desligar o runner do Forgejo após validação; (e) runner self-hosted do GitHub no homeserver + `bun` no homeserver (deploy; não bloqueia PRs); (f) push → PR → CI → merge. O PR em si não depende de (d)/(e). + +## Fase 2 — entrega sucessora (pós-validação) + +Registrada no fechamento desta Issue (capture-review-debts, autônomo): remove `.forgejo/workflows/*`, `scripts/forgejo-pr-automerge.mjs` e docs residuais 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 (servidor). +- Re-engenharia do deploy além do necessário — só a origem do clone, o modo de invocação (local, sem SSH) e o destino final mudam; rsync `--delete` preservado. +- Sincronizar o repo Forgejo com main — congelado no cutover (decidido; tracker intacto por API). +- Migrar Issues/labels/claims — anti-goal explícito. +- e2e local afetado / scoping do CI (teqo OPS72) — fora de escopo (e2e full preservado). +- Upgrade do Forgejo / infra do homeserver — decisões separadas. +- Secrets (`FORGEJO_API_TOKEN` no GitHub, `GITHUB_TOKEN` PAT local, runner) — passos manuais documentados no runbook, não código. + +## Riscos e mitigação + +- **Literal do required check:** `checks` (nome do check-run/job), pinado no spec + verificado ao vivo no primeiro PR (teqo: validado no PR #742). Errado → PRs não mergeam (fail-closed, visível) — nunca merge sem verificação. +- **GITHUB_TOKEN vs repo:** `fsolla/iara-pwa` é repo de usuário — o token built-in do Actions com `pull-requests`+`contents` write arma auto-merge normalmente (o bloqueio de repo de org era específico do Forgejo — I10, absorvida; flip usa `FORGEJO_API_TOKEN`, nunca o GITHUB_TOKEN). +- **Flip no repo errado (deltas #1):** `FORGEJO_REPOSITORY` + `FORGEJO_API_URL` explícitos em TODOS os workflows de flip + fail-loud (exit 1) — o 403/404 da era I10 não repete em silêncio. +- **Fork PR (repo público):** `if:` same-repo em todos os jobs que usam checkout/secrets (guarda RCE preservada do teqo). +- **Runner do homeserver ainda não instalado / sem bun:** deploy indisponível até o passo manual (e); PRs mergeam normalmente (CI hosted não depende dele); o PR deste item não publica nada sozinho (dispatch manual). +- **Runner do Forgejo ligado durante a transição:** com o repo congelado, o ci.yml antigo rodaria no push do main congelado... não há push novo (origin vira GitHub) — runner desligado no passo (d), reversível. +- **`gate` local vs `origin/main`:** sem o passo (a) dos remotes, o diff local compara contra o Forgejo defasado — passo (a) é o PRIMEIRO do cutover e o runbook avisa (comando exato). +- **Deploy duplicado (dispatch repetido do mesmo SHA):** marker `~/.deployed-sha` + HEAD guard — no-op, molde do teqo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo por API, deploy manual verify→self-hosted, rollup `checks` required check, `bun run pr` com semântica preservada) +- [ ] Cutover em duas fases: sistema GitHub criado e validado ao vivo primeiro; arquivos do Forgejo preservados nesta entrega (rollback), remoção em entrega sucessora +- [ ] Invariantes AGENTS/engineering-brief (plain Node stdlib nos helpers de CI; zero-dep nos workflows; sem migration; tracker intocado) +- [ ] Testes de domínio previstos: github-api (normalização/endpoints), github-pr-flow (decisões de skip/auto-merge — pins OPS57/OPS64), github-branch-protection (regra desejada + drift) diff --git a/opencode.jsonc b/opencode.jsonc index 41d9459..e3f60f6 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -13,6 +13,8 @@ "stalwart": { "enabled": false } // penpot: config de repo do teqo (design); sem fluxo de design na Iara — // nem chega a carregar, então não há entrada aqui. - // github: já desligado nos globais (fluxo migrou para o Forgejo). + // github: fluxo de PR/CI voltou para o GitHub (OPS1), mas é dirigido por + // scripts (`bun run pr`, `github-pr-automerge.mjs`, configure) — o MCP + // github continua desligado; o `forgejo` MCP é o tracker de Issues. } } diff --git a/package.json b/package.json index 3f7561f..e3a7be5 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,9 @@ "agent:file-miss": "node scripts/agent-file-miss.mjs", "agent:status": "node scripts/agent-status.mjs", "agent:promote-related": "node scripts/agent-promote-related-on-merge.mjs", - "pr:automerge": "node scripts/forgejo-pr-automerge.mjs", - "issue:transition": "node scripts/forgejo-issue-transition.mjs" + "pr:automerge": "node scripts/github-pr-automerge.mjs", + "issue:transition": "node scripts/forgejo-issue-transition.mjs", + "configure:branch-protection": "node scripts/configure-branch-protection.mjs" }, "dependencies": { "@assistant-ui/react": "^0.15.14", diff --git a/scripts/configure-branch-protection.mjs b/scripts/configure-branch-protection.mjs new file mode 100644 index 0000000..dbd94c6 --- /dev/null +++ b/scripts/configure-branch-protection.mjs @@ -0,0 +1,77 @@ +/** + * Drift repair for `main` branch protection — GitHub (OPS1). Idempotent: + * reads the existing rule and creates / updates / no-ops (never duplicates). + * + * bun run configure:branch-protection # apply + * bun run configure:branch-protection -- --dry-run + * + * Desired rule: required check-run `checks` (the ci-pr cascade job), + * strict=false, 0 reviews, `enforce_admins: true` (nobody, admin included, + * merges with a red required check). PRs auto-merge when that check is + * green; the server rule is the final defense against every merge path, + * manual API merges included. + */ + +import { createApi } from './lib/github-api.mjs' +import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() + +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.GITHUB_REPOSITORY ?? 'fsolla/iara-pwa' +const existing = await api.getBranchProtection(branch) +const { action } = planBranchProtectionRule(existing) + +console.log( + `[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${ + action === 'noop' + ? 'já conforme — nada a fazer' + : action === 'update' + ? 'atualizar (drift)' + : 'criar' + }`, +) + +if (action === 'noop') { + console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`) + process.exit(0) +} + +if (dryRun) { + console.log( + `[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`, + ) + process.exit(0) +} + +await api.updateBranchProtection(DESIRED_RULE, branch) + +const after = await api.getBranchProtection(branch) +if (!after) { + console.error( + `[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(after)}`, +) \ No newline at end of file diff --git a/scripts/deploy-iara.sh b/scripts/deploy-iara.sh new file mode 100644 index 0000000..332d3f6 --- /dev/null +++ b/scripts/deploy-iara.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Deploy Iara (SPA estático) para o homeserver (OPS1). +# +# Roda NO homeserver via o runner self-hosted do GitHub (job `deploy` de +# .github/workflows/deploy.yml): +# bash scripts/deploy-iara.sh +# (A invocação da era Forgejo — `rsync dist/ homeserver:...` via SSH a partir +# da workstation — foi substituída: o runner executa localmente, sem SSH, sem +# envolvimento da workstation.) +# +# Flow: HEAD guard (só o HEAD atual de main deploya) -> flock (serialização) +# -> guard de idempotência (marker do SHA já deployado) -> workspace fetch no +# SHA -> bun install + build -> rsync local do dist para o diretório servido. +# Qualquer falha deixa o site atual intocado e o job vermelho. +# +# Environment defaults assumem o layout do homeserver: repo clonado de +# github.com/fsolla/iara-pwa (público — sem credencial; o clone pré-cutover +# apontava para o Forgejo local e é re-apontado idempotentemente abaixo). +# Requer `bun` no homeserver (passo manual do cutover). + +set -euo pipefail + +SHA="${1:?usage: deploy-iara.sh }" +IARA_REPO_URL="${IARA_REPO_URL:-https://github.com/fsolla/iara-pwa.git}" +WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/iara-deploy}" +SERVE_DIR="${SERVE_DIR:-$HOME/iara-pwa/dist}" +DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/iara-deploy.lock}" +MARKER_FILE="${MARKER_FILE:-$HOME/iara-pwa/.deployed-sha}" +BUN_BIN="${BUN:-$(command -v bun || echo "$HOME/.bun/bin/bun")}" + +say() { printf '[deploy] %s\n' "$*"; } + +fatal() { + trap - ERR + say "FAILED: $*" >&2 + exit 1 +} + +# --- guards ------------------------------------------------------------ + +main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')" +if [ "$main_head" != "$SHA" ]; then + say "stale run: main is $main_head, job deploys $SHA — skipping" + exit 0 +fi + +exec 9>"$DEPLOY_LOCK" +flock -w 3600 9 || fatal "another deploy holds $DEPLOY_LOCK" + +main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')" +if [ "$main_head" != "$SHA" ]; then + say "stale run after lock: main is $main_head, job deploys $SHA — skipping" + exit 0 +fi + +# --- idempotency ------------------------------------------------------- +# Um dispatch manual duplicado do mesmo SHA é um no-op: o marker guarda o +# SHA já servido em produção (a ausência do marker conta como "não +# deployado" — o deploy prossegue, direção segura). + +if [ -f "$MARKER_FILE" ] && [ "$(cat "$MARKER_FILE")" = "$SHA" ]; then + say "already deployed: $SERVE_DIR runs $SHA — nothing to do" + exit 0 +fi + +# --- workspace at ------------------------------------------------ + +if [ ! -d "$WORKSPACE_DIR/.git" ]; then + say "cloning $IARA_REPO_URL into $WORKSPACE_DIR" + git clone "$IARA_REPO_URL" "$WORKSPACE_DIR" +else + # OPS1: o clone pré-cutover apontava para o Forgejo local — re-aponta + # idempotentemente para o repo que recebeu o merge. + current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)" + if [ -n "$current_url" ] && [ "$current_url" != "$IARA_REPO_URL" ]; then + say "re-pointing workspace origin: $current_url -> $IARA_REPO_URL" + git -C "$WORKSPACE_DIR" remote set-url origin "$IARA_REPO_URL" + fi + git -C "$WORKSPACE_DIR" fetch -q origin +fi + +git -C "$WORKSPACE_DIR" checkout -q --detach "$SHA" +say "workspace at $SHA" + +# --- build ------------------------------------------------------------- + +"$BUN_BIN" install --frozen-lockfile --cwd "$WORKSPACE_DIR" +"$BUN_BIN" run --cwd "$WORKSPACE_DIR" build + +# --- sync (local — o runner já está no homeserver) ---------------------- + +mkdir -p "$SERVE_DIR" +rsync -a --delete "$WORKSPACE_DIR/dist/" "$SERVE_DIR/" +printf '%s\n' "$SHA" > "$MARKER_FILE" + +say "deploy concluido em $(date -u +%H:%M:%SZ) — $SERVE_DIR runs $SHA" \ No newline at end of file diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/forgejo-issue-transition.mjs index d89bbea..9d2691f 100644 --- a/scripts/forgejo-issue-transition.mjs +++ b/scripts/forgejo-issue-transition.mjs @@ -1,11 +1,22 @@ /** * 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. + * + * OPS1: the PR now lives on GitHub; the ISSUES stay on the Forgejo tracker. + * The workflow passes the PR body via the `PR_BODY` env (from the GitHub + * event payload) and the script flips Issues on the Forgejo 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 OPS1 Fase 2 removal). + * + * Fail-loud (I10 absorbed): a failed flip exits 1 — the flip is the whole + * purpose of this workflow, and the era of silent failures proved that a + * swallowed error ends the job "success" while the status labels lie. + * Sibling issues are still attempted (the loop does not abort), but the job + * goes red so the breakage is visible. * * node scripts/forgejo-issue-transition.mjs --pr + * PR_BODY='Closes #17' node scripts/forgejo-issue-transition.mjs --pr */ import { createApi } from './lib/forgejo-api.mjs' @@ -32,7 +43,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) @@ -51,17 +66,25 @@ if (numbers.length === 0) { process.exit(0) } +let flipFailed = false for (const number of numbers) { try { 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).`, ) console.log(`[forgejo-issue-transition] #${number}: done + in-prod`) } catch (error) { - console.log( - `[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`, + flipFailed = true + console.error( + `[forgejo-issue-transition] #${number}: FLIP FALHOU — ${error instanceof Error ? error.message : error}`, ) } } +if (flipFailed) { + console.error( + `[forgejo-issue-transition] PR #${prNumber}: ${numbers.length} Issue(s) citada(s), pelo menos 1 flip falhou — os labels de status podem estar mentindo.`, + ) + process.exit(1) +} diff --git a/scripts/github-pr-automerge.mjs b/scripts/github-pr-automerge.mjs new file mode 100644 index 0000000..7bf14d1 --- /dev/null +++ b/scripts/github-pr-automerge.mjs @@ -0,0 +1,63 @@ +/** + * Plain-Node CLI (no bun) — GitHub PR safety net: arm GitHub's native + * auto-merge (rebase) for same-repo PRs to main. Runs in + * .github/workflows/agent-pr-ready-automerge.yml with the built-in + * GITHUB_TOKEN (permissions: contents + pull-requests write). + * + * Unlike the Forgejo-era automerger, there is NO poll loop: + * `enablePullRequestAutoMerge` is server-side — GitHub only merges when every + * required check (incl. `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 + * preserved: any draft is the actor's veto (skip, exit 0). + * + * Idempotent: already merged / closed / non-main base / draft → 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 + */ + +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 ') + 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) + } + + 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 "checks" fica verde`, + ) +} catch (error) { + console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`) + process.exit(1) +} \ No newline at end of file diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs new file mode 100644 index 0000000..5f5e754 --- /dev/null +++ b/scripts/lib/github-api.mjs @@ -0,0 +1,267 @@ +/** + * GitHub REST + GraphQL client for the CI/PR scripts (`pr`, `github-pr*`, + * branch protection). Zero dependencies — plain Node `fetch` — so it also runs + * inside GitHub Actions jobs without `bun install`. + * + * Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in + * workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`. + * Repository: `GITHUB_REPOSITORY` (set by `loadProjectEnv` from + * `.forgejo/worktree.env` locally; by the runner in Actions), else the default. + * + * Retry (same policy as forgejo-api): 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. + * + * Portado do teqo (scripts/lib/github-api.mjs, OPS71) com o default de repo + * da Iara (`fsolla/iara-pwa`). + */ + +const DEFAULT_BASE_URL = 'https://api.github.com' +const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql' +const DEFAULT_REPOSITORY = 'fsolla/iara-pwa' +const USER_AGENT = 'iara-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; body?: string }) => Promise} [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} [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 + }, + + /** POST /pulls — creates a PR (never draft — repo rule). */ + 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, + nodeId: pr.node_id ?? '', + } + }, + + /** + * 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 required check 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 +} \ No newline at end of file diff --git a/scripts/lib/github-branch-protection.mjs b/scripts/lib/github-branch-protection.mjs new file mode 100644 index 0000000..b3cfc74 --- /dev/null +++ b/scripts/lib/github-branch-protection.mjs @@ -0,0 +1,74 @@ +/** + * Pure decision for the GitHub `main` branch protection rule (OPS1). + * + * The desired rule is the repo contract: main requires the ci-pr cascade + * check-run 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 Forgejo-era "no server-side protection" invariant is replaced: + * on GitHub the rule IS the final defense against every merge path, manual + * API merges included). + * + * Context literal (verified live on the teqo PR #742, 2026-08-19 — same + * GitHub infra): the required-check matching uses the CHECK-RUN NAME — the + * bare JOB name (`checks`), NOT the UI display ` / `. The latter never matches and leaves the PR mergeable_state + * "blocked" forever. + */ + +export const REQUIRED_CHECK_CONTEXT = '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' } +} \ No newline at end of file diff --git a/scripts/lib/github-pr-flow.mjs b/scripts/lib/github-pr-flow.mjs new file mode 100644 index 0000000..951a38a --- /dev/null +++ b/scripts/lib/github-pr-flow.mjs @@ -0,0 +1,28 @@ +/** + * Pure decision logic for the GitHub PR auto-merge safety net + * (scripts/github-pr-automerge.mjs) and `bun run pr -- --automerge` + * (scripts/pr.mjs). Unit-tested in tests/unit/github-pr-flow.test.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 decision is reduced to: skip (non-main base, draft veto, already + * merged/closed) or arm auto-merge. + * + * Unlike the teqo OPS71 port, there is NO `cursor/*` mark-ready path: Iara + * has no draft-creating flow (`bun run pr` never drafts) — any draft is the + * actor's veto, always skipped. + */ + +/** + * @param {object|null} pr - normalized PR from github-api (or null) + * @returns {{ action: 'skip' | '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) return { action: 'skip', reason: 'draft-veto' } + return { action: 'enable-auto-merge', reason: 'ready' } +} \ No newline at end of file diff --git a/scripts/lib/load-project-env.mjs b/scripts/lib/load-project-env.mjs index b6e3b41..8f40a9f 100644 --- a/scripts/lib/load-project-env.mjs +++ b/scripts/lib/load-project-env.mjs @@ -4,6 +4,10 @@ * permite que os scripts rodem por qualquer caminho (comando `/worktree` do * opencode, `bun run issue`, roteador de terminal) sem depender de exportação * global. Arquivo commitado, sem segredos. + * + * OPS1: além do tracker (FORGEJO_REPOSITORY), também resolve o repo GitHub + * (GITHUB_REPOSITORY) — o default dos scripts de PR/CI (`pr.mjs`, + * `configure-branch-protection.mjs`). */ import { existsSync, readFileSync } from 'node:fs' @@ -52,11 +56,14 @@ export const loadProjectEnv = (from = process.cwd()) => { const repo = process.env.FORGEJO_REPOSITORY ?? parsed.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'amana/iara-pwa' + const githubRepo = + process.env.GITHUB_REPOSITORY ?? parsed.GITHUB_REPOSITORY ?? 'fsolla/iara-pwa' const worktreesRoot = expandHome( process.env.WORKTREES_ROOT ?? parsed.WORKTREES_ROOT ?? `~/.worktrees/${repo.split('/')[1] ?? 'work'}`, ) if (!process.env.FORGEJO_REPOSITORY) process.env.FORGEJO_REPOSITORY = repo + if (!process.env.GITHUB_REPOSITORY) process.env.GITHUB_REPOSITORY = githubRepo if (!process.env.WORKTREES_ROOT) process.env.WORKTREES_ROOT = worktreesRoot return { repo, worktreesRoot, envFile: file } diff --git a/scripts/pr.mjs b/scripts/pr.mjs index 9a99146..61cb346 100644 --- a/scripts/pr.mjs +++ b/scripts/pr.mjs @@ -1,57 +1,73 @@ /** - * `bun run pr` — cria PR para `main` (Ready ou draft) e, opcionalmente, aguarda - * os checks e mergea por rebase. Equivalente local do `gh pr create` + - * `gh pr merge --auto --rebase` do fluxo do teqo, sem depender do `gh`. + * `bun run pr` — cria PR para `main` no GitHub (Ready, nunca draft) e, + * opcionalmente, arma o auto-merge nativo. Equivalente local do + * `gh pr create` + `gh pr merge --auto --rebase` do fluxo do teqo, sem + * depender do `gh` (plain Node zero-dep sobre a REST/GraphQL do GitHub). * - * bun run pr -- --head [--title "…"] [--body "…"] [--draft] [--automerge] + * bun run pr -- --head [--title "…"] [--body "…"] [--automerge] * * Defaults: head = branch atual, base = main, title = primeiro commit, body vazio. - * Com `--automerge`, espera o status check `checks` ficar green e mergea - * (rebase). `--no-pr` não é suportado — criação é o objetivo. + * Com `--automerge`, o PR nasce com o auto-merge armado (GraphQL, rebase) — + * o servidor mergea quando o required check `checks` ficar verde (sem poll + * local; a semântica "cria e mergea quando os checks passarem" é preservada + * pela garantia do servidor). `--draft` foi REMOVIDO — PR nunca draft é regra + * do repo (estrutural, como no teqo OPS71). */ import { execFileSync } from 'node:child_process' +import { dieWithLabel } from './lib/cli.mjs' +import { createApi } from './lib/github-api.mjs' import { loadProjectEnv } from './lib/load-project-env.mjs' -import { dieAgent, parseArgs } from './lib/agent-forgejo.mjs' loadProjectEnv() -const forgejoModule = await import('./lib/forgejo-api.mjs');; -const { createApi } = forgejoModule - -const die = dieAgent('pr') const api = createApi({}) -const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['head', 'title', 'body'])) + +const die = dieWithLabel('pr') + +const parseArgs = (argv, flagsWithValue) => { + const flags = {} + const positional = [] + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg.startsWith('--')) { + const name = arg.slice(2) + if (flagsWithValue.has(name)) { + const next = argv[index + 1] + flags[name] = typeof next === 'string' && next.startsWith('--') ? undefined : next + index += 1 + } else { + flags[name] = true + } + } else { + positional.push(arg) + } + } + return { flags, positional } +} const currentBranch = () => execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim() const firstCommitSubject = () => execFileSync('git', ['log', '-1', '--format=%s'], { encoding: 'utf8' }).trim() +const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['head', 'title', 'body'])) + const head = flags.head ?? positional[0] ?? currentBranch() if (!head) die('Não consegui detectar a branch atual (detached?) — use --head .') if (head === 'main') die('PR para main não faz sentido a partir de main.') +if (flags.draft) die('`--draft` foi removido — PR da Iara é sempre Ready (regra do repo).') const title = flags.title ?? firstCommitSubject() const body = flags.body ?? '' console.log(`[pr] criando PR ${head} → main: ${title}`) -const pr = await api.createPullRequest({ - head, - base: 'main', - title, - body, - draft: Boolean(flags.draft), -}) -console.log(`[pr] PR #${pr.number} criado: ${pr.htmlUrl}${pr.isDraft ? ' (draft)' : ''}`) +const pr = await api.createPullRequest({ head, base: 'main', title, body }) +console.log(`[pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`) if (flags.automerge) { - console.log(`[pr] aguardando checks do PR #${pr.number} e mergeando…`) - const merged = await api.autoMerge(pr.number, { - log: (line) => console.log(`[pr] ${line}`), - }) + console.log(`[pr] armando auto-merge do PR #${pr.number} (rebase)…`) + await api.enableAutoMerge(pr.nodeId) console.log( - merged - ? `[pr] PR #${pr.number} mergeado (rebase)` - : `[pr] PR #${pr.number} já mergeado ou fechado`, + `[pr] auto-merge armado — o GitHub mergea quando o required check "checks" ficar verde`, ) -} +} \ No newline at end of file diff --git a/tests/unit/github-api.test.ts b/tests/unit/github-api.test.ts new file mode 100644 index 0000000..5c7077d --- /dev/null +++ b/tests/unit/github-api.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { createApi } from "../../scripts/lib/github-api.mjs"; + +const ok = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const apiWith = (handler: (url: string, init?: Record) => Response) => + createApi({ + token: "t", + repository: "fsolla/iara-pwa", + retries: 0, + fetchImpl: async (url: string | URL, init?: Record) => + handler(String(url), init), + }); + +describe("github-api", () => { + test("getPullRequest normaliza o shape do PR", async () => { + let seen = ""; + const api = apiWith((url) => { + seen = url; + return ok({ + number: 7, + title: "t", + body: "Closes #1", + state: "open", + draft: false, + mergeable: true, + merged_at: null, + node_id: "PR_kwDO", + head: { ref: "OPS1-x", sha: "abc" }, + base: { ref: "main" }, + }); + }); + + const pr = await api.getPullRequest(7); + + expect(seen).toBe("https://api.github.com/repos/fsolla/iara-pwa/pulls/7"); + expect(pr).toEqual({ + number: 7, + title: "t", + body: "Closes #1", + state: "OPEN", + merged: false, + draft: false, + mergeable: true, + nodeId: "PR_kwDO", + head: { ref: "OPS1-x", sha: "abc" }, + base: { ref: "main" }, + }); + }); + + test("getPullRequest 404 → null", async () => { + const api = apiWith(() => ok({ message: "Not Found" }, 404)); + expect(await api.getPullRequest(999)).toBeNull(); + }); + + test("createPullRequest POST /pulls e devolve nodeId (para auto-merge)", async () => { + let body: Record = {}; + const api = apiWith((_url, init) => { + if (!init || init.method !== "POST") throw new Error("esperava POST"); + body = JSON.parse(String(init.body)) as Record; + return ok({ number: 7, title: "t", html_url: "https://github.com/x/7", node_id: "PR_1" }); + }); + + const pr = await api.createPullRequest({ head: "OPS1-x", title: "t", body: "Closes #1" }); + + expect(body).toEqual({ head: "OPS1-x", base: "main", title: "t", body: "Closes #1" }); + expect(pr).toEqual({ number: 7, title: "t", htmlUrl: "https://github.com/x/7", nodeId: "PR_1" }); + }); + + test("getBranchProtection normaliza para drift; 404 → null", async () => { + let seen = ""; + const api = apiWith((url) => { + seen = url; + return ok({ + required_status_checks: { strict: false, checks: [{ context: "checks" }] }, + enforce_admins: { enabled: true }, + required_pull_request_reviews: null, + }); + }); + + const rule = await api.getBranchProtection("main"); + + expect(seen).toBe("https://api.github.com/repos/fsolla/iara-pwa/branches/main/protection"); + expect(rule).toEqual({ + required_status_checks: { strict: false, contexts: ["checks"] }, + enforce_admins: true, + required_pull_request_reviews: null, + }); + + const api404 = apiWith(() => ok({}, 404)); + expect(await api404.getBranchProtection("main")).toBeNull(); + }); + + test("enableAutoMerge envia mutation GraphQL com mergeMethod REBASE", async () => { + let payload: { query?: string; variables?: Record } = {}; + const api = apiWith((url, init) => { + if (!url.includes("graphql")) throw new Error(`esperava GraphQL, vi ${url}`); + payload = JSON.parse(String(init?.body)) as { query?: string; variables?: Record }; + return ok({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 7 } } } }); + }); + + await api.enableAutoMerge("PR_1"); + + expect(payload?.query).toContain("enablePullRequestAutoMerge"); + expect(payload?.variables).toEqual({ id: "PR_1", mergeMethod: "REBASE" }); + }); + + test("enableAutoMerge propaga erro do GraphQL (exit 1 no CLI)", async () => { + const api = apiWith(() => ok({ errors: [{ message: "auto-merge disabled" }] })); + await expect(api.enableAutoMerge("PR_1")).rejects.toThrow("auto-merge disabled"); + }); +}); diff --git a/tests/unit/github-branch-protection.test.ts b/tests/unit/github-branch-protection.test.ts new file mode 100644 index 0000000..a6f0763 --- /dev/null +++ b/tests/unit/github-branch-protection.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +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", () => { + test("REQUIRED_CHECK_CONTEXT é o literal do check-run (nome do job `checks`)", () => { + expect(REQUIRED_CHECK_CONTEXT).toBe("checks"); + }); + + test("ruleMatches: regra conforme → true", () => { + expect(ruleMatches(compliant())).toBe(true); + }); + + test("ruleMatches: sem regra → false", () => { + expect(ruleMatches(null)).toBe(false); + }); + + test("ruleMatches: enforce_admins false → false", () => { + expect(ruleMatches({ ...compliant(), enforce_admins: false })).toBe(false); + }); + + test("ruleMatches: context extra → false (drift)", () => { + expect( + ruleMatches({ + ...compliant(), + required_status_checks: { strict: false, contexts: [REQUIRED_CHECK_CONTEXT, "outro"] }, + }), + ).toBe(false); + }); + + test("ruleMatches: strict true → false", () => { + expect( + ruleMatches({ + ...compliant(), + required_status_checks: { strict: true, contexts: [REQUIRED_CHECK_CONTEXT] }, + }), + ).toBe(false); + }); + + test("plan: sem regra → create; conforme → noop; drift → update", () => { + expect(planBranchProtectionRule(null)).toEqual({ action: "create" }); + expect(planBranchProtectionRule(compliant())).toEqual({ action: "noop" }); + expect( + planBranchProtectionRule({ ...compliant(), required_status_checks: { strict: false, contexts: [] } }), + ).toEqual({ action: "update" }); + }); + + test("DESIRED_RULE usa apenas `checks` moderno (sem `contexts` legacy)", () => { + expect(DESIRED_RULE.required_status_checks.checks).toEqual([ + { context: REQUIRED_CHECK_CONTEXT }, + ]); + expect("contexts" in DESIRED_RULE.required_status_checks).toBe(false); + expect(DESIRED_RULE.enforce_admins).toBe(true); + expect(DESIRED_RULE.required_pull_request_reviews).toBeNull(); + }); +}); diff --git a/tests/unit/github-pr-flow.test.ts b/tests/unit/github-pr-flow.test.ts new file mode 100644 index 0000000..c27bff7 --- /dev/null +++ b/tests/unit/github-pr-flow.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { decideAutomergeAction } from "../../scripts/lib/github-pr-flow.mjs"; + +const pr = (overrides: Record = {}) => ({ + number: 7, + title: "t", + body: "", + state: "OPEN", + merged: false, + draft: false, + mergeable: true, + nodeId: "PR_1", + head: { ref: "OPS1-x", sha: "abc" }, + base: { ref: "main" }, + ...overrides, +}); + +describe("github-pr-flow", () => { + test("skip: PR inexistente", () => { + expect(decideAutomergeAction(null)).toEqual({ action: "skip", reason: "pr-inexistente" }); + }); + + test("skip: já mergeada", () => { + expect(decideAutomergeAction(pr({ merged: true }))).toEqual({ + action: "skip", + reason: "ja-mergeada", + }); + }); + + test("skip: PR fechada", () => { + expect(decideAutomergeAction(pr({ state: "CLOSED" }))).toEqual({ + action: "skip", + reason: "pr-nao-aberta", + }); + }); + + test("skip: base não é main", () => { + expect(decideAutomergeAction(pr({ base: { ref: "release" } }))).toEqual({ + action: "skip", + reason: "base-nao-main", + }); + }); + + test("skip: draft é veto do ator (qualquer branch)", () => { + expect(decideAutomergeAction(pr({ draft: true }))).toEqual({ + action: "skip", + reason: "draft-veto", + }); + }); + + test("enable-auto-merge: PR open, não-draft, base main", () => { + expect(decideAutomergeAction(pr())).toEqual({ action: "enable-auto-merge", reason: "ready" }); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 73b7127..7b9781e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -13,6 +13,7 @@ "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, + "allowJs": true, /* Linting */ "noUnusedLocals": true, -- 2.45.2 From 26b70c3342410df9c21099e989b4263757cb85ff Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 23:19:14 -0300 Subject: [PATCH 2/6] =?UTF-8?q?simplify:=20p=C3=B3s-OPS1=20=E2=80=94=20tok?= =?UTF-8?q?en=20m=C3=ADnimo=20no=20automerge,=20drift=20completo=20do=20br?= =?UTF-8?q?anch=20protection,=20retry=20documentado,=20deploy=20robusto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/agent-pr-ready-automerge.yml | 7 +- docs/ops/deploy-github-runbook.md | 4 + scripts/configure-branch-protection.mjs | 14 ++- scripts/deploy-iara.sh | 15 ++- scripts/github-pr-automerge.mjs | 2 +- scripts/lib/github-api.mjs | 33 ++++--- scripts/lib/github-branch-protection.mjs | 16 ++- scripts/lib/github-pr-flow.mjs | 2 +- scripts/pr.mjs | 17 ++-- tests/unit/github-api.test.ts | 99 ++++++++++++++++++- tests/unit/github-branch-protection.test.ts | 15 +++ 11 files changed, 186 insertions(+), 38 deletions(-) diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml index 5a4ad1a..94199d0 100644 --- a/.github/workflows/agent-pr-ready-automerge.yml +++ b/.github/workflows/agent-pr-ready-automerge.yml @@ -11,8 +11,9 @@ # mergeia quando todo required check (o check-run `checks` do ci.yml) está # verde e o PR é mergeável. Nada a pollar, nada a confiar localmente. # -# Permissions: contents + pull-requests write — o suficiente para a mutation -# GraphQL com o GITHUB_TOKEN built-in. +# Permissions mínimas: a mutation de auto-merge exige só `pull-requests: +# write` — `contents: read` (default) basta para o checkout; nada de token de +# push para o código do branch de uma PR (mesmo em repo com colaboradores). name: PR Ready + auto-merge @@ -22,7 +23,7 @@ on: branches: [main] permissions: - contents: write + contents: read pull-requests: write jobs: diff --git a/docs/ops/deploy-github-runbook.md b/docs/ops/deploy-github-runbook.md index 726047c..45c23f9 100644 --- a/docs/ops/deploy-github-runbook.md +++ b/docs/ops/deploy-github-runbook.md @@ -47,6 +47,10 @@ workstation não roda mais a suíte de CI. + `bun run build` lá — precisa existir no PATH do runner; `BUN` env sobrepõe o default). - Instalar o `actions-runner` com labels `self-hosted, homeserver`. + - **Não ligar** "Send workflows to a self-hosted runner" para fork PRs + (default OFF no GitHub): o repo é público e o `deploy` executa código na + máquina de produção — se esse setting ligar algum dia, um fork PR pode + virar RCE no homeserver. - Não bloqueia PRs: o deploy é manual e só existe depois do runner. 6. **(f) Push → PR → CI → merge → flips.** O PR do próprio OPS1 valida o fluxo diff --git a/scripts/configure-branch-protection.mjs b/scripts/configure-branch-protection.mjs index dbd94c6..96dcece 100644 --- a/scripts/configure-branch-protection.mjs +++ b/scripts/configure-branch-protection.mjs @@ -13,7 +13,11 @@ */ import { createApi } from './lib/github-api.mjs' -import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs' +import { + DESIRED_RULE, + planBranchProtectionRule, + ruleMatches, +} from './lib/github-branch-protection.mjs' import { loadProjectEnv } from './lib/load-project-env.mjs' loadProjectEnv() @@ -37,7 +41,7 @@ const dryRun = flags.dryRun === true const branch = 'main' const api = createApi({}) -const repo = process.env.GITHUB_REPOSITORY ?? 'fsolla/iara-pwa' +const repo = process.env.GITHUB_REPOSITORY const existing = await api.getBranchProtection(branch) const { action } = planBranchProtectionRule(existing) @@ -66,12 +70,12 @@ if (dryRun) { await api.updateBranchProtection(DESIRED_RULE, branch) const after = await api.getBranchProtection(branch) -if (!after) { +if (!after || !ruleMatches(after)) { console.error( - `[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" não aparece no servidor.`, + `[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" no servidor não bate com a desejada — ${JSON.stringify(after)}`, ) process.exit(1) } console.log( `[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`, -) \ No newline at end of file +) diff --git a/scripts/deploy-iara.sh b/scripts/deploy-iara.sh index 332d3f6..45b2eb1 100644 --- a/scripts/deploy-iara.sh +++ b/scripts/deploy-iara.sh @@ -32,11 +32,14 @@ BUN_BIN="${BUN:-$(command -v bun || echo "$HOME/.bun/bin/bun")}" say() { printf '[deploy] %s\n' "$*"; } fatal() { - trap - ERR say "FAILED: $*" >&2 exit 1 } +# --- bun no homeserver (passo manual do cutover) ------------------------- + +[ -x "$BUN_BIN" ] || fatal "bun não encontrado em $BUN_BIN — instale no homeserver (ou defina BUN)" + # --- guards ------------------------------------------------------------ main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')" @@ -46,7 +49,9 @@ if [ "$main_head" != "$SHA" ]; then fi exec 9>"$DEPLOY_LOCK" -flock -w 3600 9 || fatal "another deploy holds $DEPLOY_LOCK" +# ~20min de espera pelo lock: o job tem timeout de 30min — esperar mais que +# isso seria descartado pelo GitHub de qualquer forma. +flock -w 1200 9 || fatal "another deploy holds $DEPLOY_LOCK" main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')" if [ "$main_head" != "$SHA" ]; then @@ -80,6 +85,10 @@ else git -C "$WORKSPACE_DIR" fetch -q origin fi +# Workspace limpo e no SHA exato: um deploy anterior falhado (ou residuo de +# build) não contamina o build — `bun install` recria o que precisar. +git -C "$WORKSPACE_DIR" reset -q --hard "$SHA" +git -C "$WORKSPACE_DIR" clean -q -fdx git -C "$WORKSPACE_DIR" checkout -q --detach "$SHA" say "workspace at $SHA" @@ -94,4 +103,4 @@ mkdir -p "$SERVE_DIR" rsync -a --delete "$WORKSPACE_DIR/dist/" "$SERVE_DIR/" printf '%s\n' "$SHA" > "$MARKER_FILE" -say "deploy concluido em $(date -u +%H:%M:%SZ) — $SERVE_DIR runs $SHA" \ No newline at end of file +say "deploy concluido em $(date -u +%H:%M:%SZ) — $SERVE_DIR runs $SHA" diff --git a/scripts/github-pr-automerge.mjs b/scripts/github-pr-automerge.mjs index 7bf14d1..1f1324b 100644 --- a/scripts/github-pr-automerge.mjs +++ b/scripts/github-pr-automerge.mjs @@ -60,4 +60,4 @@ try { } catch (error) { console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`) process.exit(1) -} \ No newline at end of file +} diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 5f5e754..60711e3 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -8,11 +8,11 @@ * Repository: `GITHUB_REPOSITORY` (set by `loadProjectEnv` from * `.forgejo/worktree.env` locally; by the runner in Actions), else the default. * - * Retry (same policy as forgejo-api): 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, + * Retry (exponential backoff): um fetch que REJEITA a nível de rede + * (DNS/TCP/reset — provavelmente nunca chegou ao servidor) retrya em + * QUALQUER método; um 5xx retrya só em GET — resposta 5xx significa que o + * servidor respondeu, então escritas falham fechado ali (sem duplicar efeito + * colateral). 4xx nunca retrya. Defaults: 3 retries, base 300 ms ×2, * ±20% jitter. * * Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈ @@ -81,7 +81,7 @@ export const createApi = ({ `[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`, ) - const headers = (withJson = true) => { + const headers = () => { const authToken = token ?? process.env.GITHUB_TOKEN if (!authToken) { throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.') @@ -89,7 +89,7 @@ export const createApi = ({ return { Authorization: `Bearer ${authToken}`, 'User-Agent': USER_AGENT, - ...(withJson ? { 'Content-Type': 'application/json' } : {}), + 'Content-Type': 'application/json', } } @@ -232,10 +232,15 @@ export const createApi = ({ /** * GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for * drift comparison; `null` when the branch has no protection rule (404). + * Cobre todos os campos do DESIRED_RULE (ruleMatches os compara), então o + * drift repair é completo: reviews/restrictions sem exigência viram + * `null` (o GitHub pode ecoar um objeto com count 0), booleanos são + * desembrulhados de `{ enabled }`. */ getBranchProtection: async (branch = 'main') => { const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`) if (!rule) return null + const reviews = rule.required_pull_request_reviews return { required_status_checks: rule.required_status_checks ? { @@ -244,7 +249,13 @@ export const createApi = ({ } : null, enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins), - required_pull_request_reviews: rule.required_pull_request_reviews ?? null, + required_pull_request_reviews: + !reviews || (reviews.required_approving_review_count ?? 0) === 0 ? null : reviews, + restrictions: rule.restrictions ?? null, + required_linear_history: Boolean(rule.required_linear_history), + allow_force_pushes: Boolean(rule.allow_force_pushes?.enabled ?? rule.allow_force_pushes), + allow_deletions: Boolean(rule.allow_deletions?.enabled ?? rule.allow_deletions), + block_creations: Boolean(rule.block_creations), } }, @@ -257,11 +268,7 @@ export const createApi = ({ 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 -} \ No newline at end of file +} diff --git a/scripts/lib/github-branch-protection.mjs b/scripts/lib/github-branch-protection.mjs index b3cfc74..bb913ef 100644 --- a/scripts/lib/github-branch-protection.mjs +++ b/scripts/lib/github-branch-protection.mjs @@ -44,14 +44,22 @@ const sameStrings = (have, want) => /** * True when the existing rule (from `getBranchProtection`, already - * normalized) matches the desired rule on every compared field — array - * fields as sets. + * normalized) matches the desired rule on EVERY field of DESIRED_RULE — + * array fields as sets. Cobre o contrato inteiro: reviews/restrictions sem + * exigência e os booleanos (linear history, force pushes, deletions, + * creations) — drift em qualquer um deles é reparado. */ 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 + if (existing.required_pull_request_reviews !== DESIRED_RULE.required_pull_request_reviews) + return false + if (existing.restrictions !== DESIRED_RULE.restrictions) return false + if (existing.required_linear_history !== DESIRED_RULE.required_linear_history) return false + if (existing.allow_force_pushes !== DESIRED_RULE.allow_force_pushes) return false + if (existing.allow_deletions !== DESIRED_RULE.allow_deletions) return false + if (existing.block_creations !== DESIRED_RULE.block_creations) return false const haveStatus = existing.required_status_checks if (!haveStatus) return false if (haveStatus.strict !== wantStatus.strict) return false @@ -71,4 +79,4 @@ export const ruleMatches = (existing) => { export const planBranchProtectionRule = (existing) => { if (ruleMatches(existing)) return { action: 'noop' } return { action: existing ? 'update' : 'create' } -} \ No newline at end of file +} diff --git a/scripts/lib/github-pr-flow.mjs b/scripts/lib/github-pr-flow.mjs index 951a38a..ffaf509 100644 --- a/scripts/lib/github-pr-flow.mjs +++ b/scripts/lib/github-pr-flow.mjs @@ -25,4 +25,4 @@ export const decideAutomergeAction = (pr) => { if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' } if (pr.draft) return { action: 'skip', reason: 'draft-veto' } return { action: 'enable-auto-merge', reason: 'ready' } -} \ No newline at end of file +} diff --git a/scripts/pr.mjs b/scripts/pr.mjs index 61cb346..bcb75ec 100644 --- a/scripts/pr.mjs +++ b/scripts/pr.mjs @@ -65,9 +65,14 @@ const pr = await api.createPullRequest({ head, base: 'main', title, body }) console.log(`[pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`) if (flags.automerge) { - console.log(`[pr] armando auto-merge do PR #${pr.number} (rebase)…`) - await api.enableAutoMerge(pr.nodeId) - console.log( - `[pr] auto-merge armado — o GitHub mergea quando o required check "checks" ficar verde`, - ) -} \ No newline at end of file + try { + console.log(`[pr] armando auto-merge do PR #${pr.number} (rebase)…`) + await api.enableAutoMerge(pr.nodeId) + console.log( + `[pr] auto-merge armado — o GitHub mergea quando o required check "checks" ficar verde`, + ) + } catch (error) { + console.error(`[pr] falhou ao armar auto-merge: ${error instanceof Error ? error.message : error}`) + process.exit(1) + } +} diff --git a/tests/unit/github-api.test.ts b/tests/unit/github-api.test.ts index 5c7077d..5602631 100644 --- a/tests/unit/github-api.test.ts +++ b/tests/unit/github-api.test.ts @@ -52,6 +52,27 @@ describe("github-api", () => { }); }); + test("getPullRequest: estado fechado (caixa baixa, como o GitHub envia) e merged_at", async () => { + const api = apiWith(() => + ok({ + number: 8, + title: "t", + body: "", + state: "closed", + draft: false, + merged_at: "2026-08-20T00:00:00Z", + node_id: "PR_2", + head: { ref: "b", sha: "s" }, + base: { ref: "main" }, + }), + ); + + const pr = await api.getPullRequest(8); + + expect(pr?.state).toBe("CLOSED"); + expect(pr?.merged).toBe(true); + }); + test("getPullRequest 404 → null", async () => { const api = apiWith(() => ok({ message: "Not Found" }, 404)); expect(await api.getPullRequest(999)).toBeNull(); @@ -71,14 +92,23 @@ describe("github-api", () => { expect(pr).toEqual({ number: 7, title: "t", htmlUrl: "https://github.com/x/7", nodeId: "PR_1" }); }); - test("getBranchProtection normaliza para drift; 404 → null", async () => { + test("getBranchProtection normaliza para drift completo (reviews count 0 → null, booleanos)", async () => { let seen = ""; const api = apiWith((url) => { seen = url; return ok({ required_status_checks: { strict: false, checks: [{ context: "checks" }] }, enforce_admins: { enabled: true }, - required_pull_request_reviews: null, + required_pull_request_reviews: { + required_approving_review_count: 0, + dismiss_stale_reviews: false, + require_code_owner_reviews: false, + }, + restrictions: null, + required_linear_history: false, + allow_force_pushes: { enabled: false }, + allow_deletions: { enabled: true }, + block_creations: false, }); }); @@ -89,6 +119,11 @@ describe("github-api", () => { required_status_checks: { strict: false, contexts: ["checks"] }, enforce_admins: true, required_pull_request_reviews: null, + restrictions: null, + required_linear_history: false, + allow_force_pushes: false, + allow_deletions: true, + block_creations: false, }); const api404 = apiWith(() => ok({}, 404)); @@ -114,3 +149,63 @@ describe("github-api", () => { await expect(api.enableAutoMerge("PR_1")).rejects.toThrow("auto-merge disabled"); }); }); + +describe("github-api retry", () => { + const retryApi = (handler: (url: string, init?: Record) => Response | never) => + createApi({ + token: "t", + repository: "fsolla/iara-pwa", + retries: 2, + backoffMs: 0, + jitter: false, + sleepImpl: async () => {}, + fetchImpl: async (url: string | URL, init?: Record) => + handler(String(url), init), + }); + + test("GET 5xx (503) retrya até sucesso", async () => { + let calls = 0; + const api = retryApi(() => { + calls += 1; + if (calls === 1) return ok({ message: "Service Unavailable" }, 503); + return ok({ + number: 7, + title: "t", + body: "", + state: "open", + draft: false, + merged_at: null, + node_id: "PR_1", + head: { ref: "b", sha: "s" }, + base: { ref: "main" }, + }); + }); + + const pr = await api.getPullRequest(7); + + expect(calls).toBe(2); + expect(pr?.number).toBe(7); + }); + + test("escrita com 5xx não retrya (fail-closed — servidor respondeu)", async () => { + let calls = 0; + const api = retryApi(() => { + calls += 1; + return ok({ message: "boom" }, 503); + }); + + await expect(api.createPullRequest({ head: "x", title: "t", body: "b" })).rejects.toThrow(); + expect(calls).toBe(1); + }); + + test("rejeição de rede retrya em qualquer método (não chegou ao servidor)", async () => { + let calls = 0; + const api = retryApi(() => { + calls += 1; + throw new Error("ECONNRESET"); + }); + + await expect(api.getPullRequest(1)).rejects.toThrow("ECONNRESET"); + expect(calls).toBe(3); + }); +}); diff --git a/tests/unit/github-branch-protection.test.ts b/tests/unit/github-branch-protection.test.ts index a6f0763..bf5760c 100644 --- a/tests/unit/github-branch-protection.test.ts +++ b/tests/unit/github-branch-protection.test.ts @@ -10,6 +10,11 @@ const compliant = () => ({ required_status_checks: { strict: false, contexts: [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, }); describe("github-branch-protection", () => { @@ -47,12 +52,22 @@ describe("github-branch-protection", () => { ).toBe(false); }); + test("ruleMatches: drift em allow_deletions / restrictions é detectado", () => { + expect(ruleMatches({ ...compliant(), allow_deletions: true })).toBe(false); + expect( + ruleMatches({ ...compliant(), restrictions: { users: [], teams: [], apps: [] } }), + ).toBe(false); + }); + test("plan: sem regra → create; conforme → noop; drift → update", () => { expect(planBranchProtectionRule(null)).toEqual({ action: "create" }); expect(planBranchProtectionRule(compliant())).toEqual({ action: "noop" }); expect( planBranchProtectionRule({ ...compliant(), required_status_checks: { strict: false, contexts: [] } }), ).toEqual({ action: "update" }); + expect(planBranchProtectionRule({ ...compliant(), allow_force_pushes: true })).toEqual({ + action: "update", + }); }); test("DESIRED_RULE usa apenas `checks` moderno (sem `contexts` legacy)", () => { -- 2.45.2 From df3d0d2cb077360f58040933c34cc2c22cc2cb0c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 23:29:06 -0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(ci):=20volta=20contents:=20write=20no?= =?UTF-8?q?=20safety=20net=20de=20auto-merge=20=E2=80=94=20enablePullReque?= =?UTF-8?q?stAutoMerge=20exige=20(verificado=20ao=20vivo=20PR=20#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/agent-pr-ready-automerge.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml index 94199d0..1108227 100644 --- a/.github/workflows/agent-pr-ready-automerge.yml +++ b/.github/workflows/agent-pr-ready-automerge.yml @@ -11,9 +11,12 @@ # mergeia quando todo required check (o check-run `checks` do ci.yml) está # verde e o PR é mergeável. Nada a pollar, nada a confiar localmente. # -# Permissions mínimas: a mutation de auto-merge exige só `pull-requests: -# write` — `contents: read` (default) basta para o checkout; nada de token de -# push para o código do branch de uma PR (mesmo em repo com colaboradores). +# Permissions: a mutation GraphQL `enablePullRequestAutoMerge` exige +# `contents: write` + `pull-requests: write` com o GITHUB_TOKEN built-in — +# VERIFICADO AO VIVO (PR #2, 2026-08-20): reduzir para `contents: read` + +# `pull-requests: write` faz o job falhar com "GitHub GraphQL: Resource not +# accessible by integration". Não "otimizar" de volta para read — quebra o +# safety net. name: PR Ready + auto-merge @@ -23,7 +26,7 @@ on: branches: [main] permissions: - contents: read + contents: write pull-requests: write jobs: -- 2.45.2 From c99c2575781d8b4ffab680de41579107446eb058 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 23:41:37 -0300 Subject: [PATCH 4/6] =?UTF-8?q?docs(plans):=20planos=20de=20d=C3=A9bito=20?= =?UTF-8?q?p=C3=B3s-OPS1=20=E2=80=94=20Fase=202=20(remover=20fluxo=20Forge?= =?UTF-8?q?jo)=20e=20DRY=20(parseArgs/verify=20reusable)=20(Related=20#20,?= =?UTF-8?q?=20Related=20#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/ops1-simplify-dry-pos.md | 54 +++++++++++++++++++++++++++++ docs/plans/remover-fluxo-forgejo.md | 52 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 docs/plans/ops1-simplify-dry-pos.md create mode 100644 docs/plans/remover-fluxo-forgejo.md diff --git a/docs/plans/ops1-simplify-dry-pos.md b/docs/plans/ops1-simplify-dry-pos.md new file mode 100644 index 0000000..00285cc --- /dev/null +++ b/docs/plans/ops1-simplify-dry-pos.md @@ -0,0 +1,54 @@ +# Plano: Pós-OPS1 — consolidar parseArgs e extrair cascata `checks` em workflow reutilizável + +Status: rascunho +Atualizado em: 2026-08-20 +Issue: OPS1-DRY (sucessora de #17 OPS1) +Priority: P3 +Appetite: ~1 sessão leve (fill-in) + +## Intenção + +Débitos de DRY encontrados no review do OPS1 (simplify reviewers), que ficaram +para trás para não misturar refactor com a migração de CI: + +1. **parseArgs duplicado em 4 scripts.** `scripts/pr.mjs`, `scripts/github-pr-automerge.mjs` + e `scripts/configure-branch-protection.mjs` têm cópias locais; `scripts/lib/agent-forgejo.mjs` + exporta outro idêntico (flagsWithValue + positional + kebab→camel). Consolidar num + `scripts/lib/cli.mjs` único e importar nos 4 — sem tocar a semântica (fail-closed nos + flags sem valor, positional, prefixo `--`). +2. **Cascata `checks` duplicada em `ci.yml` e no `verify` do `deploy.yml`.** O mesmo + fluxo (checkout → setup-bun → install → lint → build → unit → playwright → e2e) em + dois arquivos; risco de drift (ex.: esquecer `playwright install` em um dos dois). + Extrair numa reusable workflow (`workflow_call`) chamada pelos dois, preservando o + nome do job `checks` em `ci.yml` (o required-check literal `checks` não pode mudar). + +## O que NÃO negociar + +- O literal `checks` (required check + auto-merge) — com reusable workflow, o job + `checks` fica declarado em `ci.yml` (o check-run `CI / checks` é inalterado). +- Comportamento/política do parser (as 3 variantes hoje divergem só em + flagsWithValue/positional — unificar com o superset; cada chamador filtra). + +## Fases + +1. `parseArgs` em `cli.mjs` + importar em `pr.mjs`, `github-pr-automerge.mjs`, + `configure-branch-protection.mjs`, `agent-forgejo.mjs` (remover as cópias). Gate: + `bun run test:unit`. +2. Reusable workflow `checks` (workflow_call) + `ci.yml` e `deploy.yml` `verify` + passando a usá-la. Gate: `bun run gate` + validação ao vivo (PR → check `checks`). + +## Rabbit holes / fora de escopo + +- Não mudar o comportamento do parser (só mover/consolidar). +- Não mesclar DRY com UX. +- `agent-forgejo.mjs` (tracker) só troca o parseArgs — nada da lógica de claim. + +## Já resolvido no simplify (não reabrir) + +- Nada do OPS1-DRY foi resolvido — é o próprio conteúdo pós-review diferido. + +## Aceite + +- Zero `parseArgs` duplicado (busca por `const parseArgs` → 1 em cli.mjs). +- `ci.yml` e `deploy.yml` `verify` usam o mesmo `workflow_call`; um PR validado mostra + o check-run `CI / checks` idêntico. \ No newline at end of file diff --git a/docs/plans/remover-fluxo-forgejo.md b/docs/plans/remover-fluxo-forgejo.md new file mode 100644 index 0000000..0c1f4e8 --- /dev/null +++ b/docs/plans/remover-fluxo-forgejo.md @@ -0,0 +1,52 @@ +# Plano: Pós-OPS1 Fase 2 — remover arquivos do fluxo Forgejo (workflows/scripts mortos) + +Status: rascunho +Atualizado em: 2026-08-20 +Issue: OPS1-F2 (sucessora de #17 OPS1) +Priority: P2 +Appetite: ~1 sessão (remoção + fixups de consumidores + docs) + +## Intenção + +O fluxo GitHub Actions (OPS1, validado ao vivo: PRs #1/#3 no GitHub — CI `checks` +verde → auto-merge nativo → flips via API → deploy manual `workflow_dispatch`) é a +única via de CI/PR/deploy no repo. Os arquivos do fluxo Forgejo ficam **dormentes** +desde o cutover e devem sair do tree — é a Fase 2 prometida no impl plan do OPS1. + +## O que NÃO negociar + +- Tracker no Forgejo (Issues/labels/claims/flips via API): `forgejo-api.mjs`, + `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, + `agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica + (consumido pelo provisionamento de worktree — fora do escopo `workflows/*`). +- Desligar o runner do Forgejo é passo manual do runbook (fora do código). +- `ci.yml` de main não volta (deploy = dispatch manual). +- Nunca fechar o tracker (anti-goal do OPS1). + +## O que remover + +- `.forgejo/workflows/*` (4 arquivos: ci.yml, issue-done-on-main-merge.yml, + plan-issue-ready-on-main-merge.yml, agent-pr-ready-automerge.yml). +- `scripts/forgejo-pr-automerge.mjs` (poll automerger da era Forgejo — substituído + pelo auto-merge nativo + `github-pr-automerge.mjs`). +- Doc residuais que descrevem o fluxo Forgejo (varredura por `forgejo-pr-automerge`, + `.forgejo/workflows`, "rsync homeserver:" etc.). + +## Rabbit holes / fora de escopo + +- Não deletar scripts do tracker (item NÃO negociar) nem `.forgejo/worktree.env`. +- Não editar `docs/plans/*` já congelado (história; a narrativa "dormentes até a + Fase 2" permanece). +- Rollback continua sendo git history / PR reversível (não manter arquivos). + +## Já resolvido no simplify (não reabrir) + +- Resistência de reverter a Fase 2 foi mudada para "rollback = git history" (o main + congelado do Forgejo ainda tem a árvore pré-cutover). + +## Aceite + +- `git grep` por `forgejo-pr-automerge|.forgejo/workflows` em scripts/workflows + vivo → zero hits (fora do tracker e do env file). +- `bun run gate` limpo; um PR inteiro validado no GitHub depois da remoção + (CI + merge + flip). \ No newline at end of file -- 2.45.2 From 6bf7cbde8376778e569973ed684642a9cfaaf07d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 20 Aug 2026 12:18:58 -0300 Subject: [PATCH 5/6] Adding Cheapest Inference as provider in opencode for this project --- opencode.jsonc | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/opencode.jsonc b/opencode.jsonc index e3f60f6..b6fc75c 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -16,5 +16,30 @@ // github: fluxo de PR/CI voltou para o GitHub (OPS1), mas é dirigido por // scripts (`bun run pr`, `github-pr-automerge.mjs`, configure) — o MCP // github continua desligado; o `forgejo` MCP é o tracker de Issues. + }, + "provider": { + "cheapest-inference": { + "npm": "@ai-sdk/openai-compatible", + "name": "Cheapest Inference", + "options": { + "baseURL": "https://api.cheapestinference.com/v1" + }, + "models": { + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "limit": { + "context": 1000000, + "output": 384000 + } + }, + "mimo-v2.5": { + "name": "MiMo v2.5", + "limit": { + "context": 1000000, + "output": 128000 + } + } + } + } } } -- 2.45.2 From 2637a9de1a9162b2cdea71c1238b50355057b0fe Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 14:43:05 -0300 Subject: [PATCH 6/6] =?UTF-8?q?chore(worktree):=20preset=20model=20do=20la?= =?UTF-8?q?uncher=20via=20OPENCODE=5FWORKTREE=5FMODEL=20=E2=80=94=20fallba?= =?UTF-8?q?ck=20comum=20aos=203=20repos=20(alinhamento=20com=20infra-solla?= =?UTF-8?q?/teqo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/lib/worktree.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 719e237..4238341 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -17,8 +17,15 @@ export const PLAN_BRANCH_PREFIX = 'plans/plan-issue' /** Env var the interactive-terminal `worktree()` function sets to request the opencode launch. */ export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL' -/** Preset model for the opencode launch — change the preset by editing this constant. */ -export const OPENCODE_PRESET_MODEL = 'deepseek/deepseek-v4-flash' +/** + * Preset model for the opencode launch — fallback comum aos 3 repos do fluxo + * (infra-solla, teqo, iara-pwa). Override sem editar código: global via + * `export OPENCODE_WORKTREE_MODEL=…` no profile (~/.bashrc), ou por repo na + * chave `OPENCODE_WORKTREE_MODEL` da `.forgejo/worktree.env` (o roteador + * `worktree()` exporta a chave antes de rodar este script). + */ +export const OPENCODE_PRESET_MODEL = + process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash' /** * Skill command sent as the launch's initial message per purpose. `next` -- 2.45.2