From f58a712ad9482327887c61be5349300fa734d450 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 14 Sep 2026 02:50:58 -0300 Subject: [PATCH] =?UTF-8?q?feat(OPS104):=20inicia=20o=20deploy=20no=20merg?= =?UTF-8?q?e,=20com=20staging=20autom=C3=A1tico=20e=20produ=C3=A7=C3=A3o?= =?UTF-8?q?=20com=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.yml: trigger push em main + preflight guard (pula se há run queued/in_progress; waiting não conta) e job hosted requeue após deploy-staging (fora do grupo deploy-homeserver) - scripts/lib/deploy-trigger.mjs (decidePreflight/decideRequeue) + CLIs deploy-preflight/deploy-requeue; github-api ganha listWorkflowRuns/getBranchHead - pins: ciSkipInvariants reescrito (push + guard + dispatch + requeue fora do grupo), deployTrigger/githubApi novos; deployScript intocado - docs realinhados (AGENTS*, AGENT-OPS, README, runbook, rules/skills) + changelog Closes #980 --- .agents/rules/agent-pr-workflow.mdc | 6 +- .agents/rules/engineering-standards.mdc | 2 +- .agents/skills/bug-fix/SKILL.md | 4 +- .agents/skills/bug-fix/postmortem-template.md | 2 +- .../skills/work-issue/execution-pipeline.md | 4 +- .github/workflows/deploy.yml | 128 ++++++++++---- AGENTS-infra.md | 4 +- AGENTS.md | 2 +- README.md | 6 +- docs/AGENT-OPS.md | 30 ++-- docs/TESTING.md | 2 +- docs/changelog/2026-09-14-ops104.md | 1 + docs/ops/teqo-1313-deploy.md | 93 ++++++---- ...ps104-disparo-automatico-do-deploy-impl.md | 166 ++++++++++++++++++ scripts/deploy-preflight.mjs | 61 +++++++ scripts/deploy-requeue.mjs | 41 +++++ scripts/issue-transition-on-merge.mjs | 2 +- scripts/lib/deploy-trigger.mjs | 55 ++++++ scripts/lib/github-api.mjs | 32 ++++ tests/unit/ciSkipInvariants.unit.spec.ts | 48 +++-- tests/unit/deployTrigger.unit.spec.ts | 87 +++++++++ tests/unit/githubApi.unit.spec.ts | 68 +++++++ 22 files changed, 736 insertions(+), 108 deletions(-) create mode 100644 docs/changelog/2026-09-14-ops104.md create mode 100644 docs/plans/ops104-disparo-automatico-do-deploy-impl.md create mode 100644 scripts/deploy-preflight.mjs create mode 100644 scripts/deploy-requeue.mjs create mode 100644 scripts/lib/deploy-trigger.mjs create mode 100644 tests/unit/deployTrigger.unit.spec.ts diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index 7b229c06..e17b3681 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -38,7 +38,7 @@ Closes # **Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired: 1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky` -2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR) +2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy, que começa no merge — OPS104), com os `docs-guards` locais (OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR) 3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch) ```bash @@ -61,7 +61,7 @@ OPS64 history: the Forgejo runner could report the LAST matrix shard's result as (`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.) -**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate. +**There is no deploy from PR CI.** `.github/workflows/deploy.yml` starts on merge to `main` (and on manual `workflow_dispatch` as the escape — OPS104): `preflight` skips the run when another deploy is active, `verify` runs the full suite (hosted), `deploy-staging` publishes automatically, and only the approved `production` environment lets the self-hosted runner on the homeserver publish `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate. When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50). @@ -69,7 +69,7 @@ Use the required check `CI (PR) / checks` — without it, you are not covering t ## Promote -There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`). +There is no stage→main promote. Merging to `main` is the delivery and starts the deploy (staging automatic; production behind the `production` environment approval) with full verification (runbook: `docs/ops/teqo-1313-deploy.md`). ## Safety net (workflow) diff --git a/.agents/rules/engineering-standards.mdc b/.agents/rules/engineering-standards.mdc index 412195a5..9d8de906 100644 --- a/.agents/rules/engineering-standards.mdc +++ b/.agents/rules/engineering-standards.mdc @@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM 1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive. 2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment. -3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`). +3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy (o run nasce no merge — OPS104); **com os `docs-guards` locais** — OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (início automático no merge, approve humano na produção — OPS104; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`). 4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.agents/skills/bug-fix/SKILL.md b/.agents/skills/bug-fix/SKILL.md index 87808914..4ad23c73 100644 --- a/.agents/skills/bug-fix/SKILL.md +++ b/.agents/skills/bug-fix/SKILL.md @@ -51,7 +51,7 @@ Fases pesadas são delegadas a sub-agentes com contexto mínimo — o agente pri - [ ] 3. Dispatch sub-agente investigador → causa-raiz - [ ] 4. Fix da causa-raiz + teste de regressão (main agent iterativo) - [ ] 5. Dispatch sub-agente verificador → prova verde -- [ ] 6. Gates + PR → merge; bug de prod: deploy manual → confirmação do humano +- [ ] 6. Gates + PR → merge; bug de prod: deploy (staging automático, produção após approve) → confirmação do humano - [ ] 7. Dispatch sub-agente estrategista → prevenção classificada - [ ] 8. Implementar prevenção barata agora (a cara fica no post-mortem) - [ ] 9. Dispatch sub-agente escritor → post-mortem + entrada de changelog @@ -106,7 +106,7 @@ Dispatch o **verificador** com o diff + causa-raiz + cenário original. Ele roda ## Passo 6 — Fechar e confirmar em prod - `pnpm push` → PR no GitHub **Ready** base `main` (`Closes #N` se houver Issue; `Related #N` para PR só de docs — plans-only guard) → auto-merge nativo com o required check `CI (PR) / checks` verde. Siga `agent-pr-workflow.mdc`. -- **Bug de prod:** o merge NÃO é o fim. O deploy é **manual** (`workflow_dispatch` de `deploy.yml`, só humano — AGENT-OPS). Peça ao humano para disparar o deploy e **espere a confirmação de que prod se comporta** (o humano relata; você nunca toca o homeserver). Só então o bug está corrigido — registre data/hora da confirmação. +- **Bug de prod:** o merge NÃO é o fim. O deploy **começa sozinho** no merge (`push` em `main` dispara o `deploy.yml` — OPS104): o staging publica automaticamente e a **produção só sai com o approve humano** no environment `production`. Peça ao humano para aprovar a produção do run e **espere a confirmação de que prod se comporta** (o humano relata; você nunca toca o homeserver). Só então o bug está corrigido — registre data/hora da confirmação. ## Passo 7 — Prevenção (sub-agente) diff --git a/.agents/skills/bug-fix/postmortem-template.md b/.agents/skills/bug-fix/postmortem-template.md index 1359c71b..e30a8124 100644 --- a/.agents/skills/bug-fix/postmortem-template.md +++ b/.agents/skills/bug-fix/postmortem-template.md @@ -20,7 +20,7 @@ | Início provável | | quando o bug começou (commit/entrega que o introduziu, se apurado) | | Detecção | | | | Correção mergeada | | | -| Deploy | | (bug de prod: dispatch manual do deploy.yml) | +| Deploy | | (bug de prod: deploy começa no merge; produção após approve do environment) | | Verificado em prod | | (bug de prod: confirmação do humano) | ## O bug diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index 0f4c15de..9365aa0b 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -40,8 +40,8 @@ antes do deploy**. - **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo `selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness) classifica **`curated`** (OPS86): o PR roda o conjunto curado de e2e — - nunca zero; o full fica para o `verify` do deploy manual (antes de - publicar) e, nesse mesmo diff, o espelho local roda full. Arquivo de área + nunca zero; o full fica para o `verify` do deploy (o run nasce no merge — + OPS104) antes de publicar e, nesse mesmo diff, o espelho local roda full. Arquivo de área de risco sem entry no manifesto (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica `unmapped-risk` e o PR **falha** listando os arquivos — a correção é adicionar a entry no diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fbebaa02..d31ce426 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,49 +1,67 @@ -# Manual deploy (OPS71 — GitHub Actions; staging + production since OPS103). -# `workflow_dispatch` only: there is NO automatic main verifier anymore -# (ci.yml eliminated in the gate 2026-08-19) — publishing is a deliberate -# operator act, and this workflow runs the FULL suite (no scope skips, e2e -# included) before any deploy job touches the homeserver. +# Deploy (OPS71 — GitHub Actions; staging + production since OPS103; +# automatic start since OPS104). # -# THREE jobs, same workflow (OPS103): +# STARTS BY ITSELF: a `push` to `main` starts the run (OPS104 trigger 1) — +# the `preflight` job skips it when another deploy run is already +# queued/in_progress (a run `waiting` on the production approval does NOT +# count: a merge in that window starts a new run and both stay pending). +# `workflow_dispatch` is kept as the manual escape (rollback/re-dispatch) and +# never goes through the guard. +# +# REQUEUES ITSELF: after a green `deploy-staging`, the hosted `requeue` job +# compares main's head with the run's SHA and dispatches a new run from the +# new head when a merge landed mid-flight (OPS104 trigger 2). It stays OUTSIDE +# the `deploy-homeserver` group — a run waiting on the production approval +# must not block the requeue. +# +# Jobs (OPS103/OPS104): +# preflight — ubuntu-latest: push-only guard (OPS104 trigger 1). # verify — ubuntu-latest (hosted): full cascade (guards for PR # contracts are left out — they gate PRs, not # production). Untouched by OPS103; it verifies the # COMMIT once for both environments. # deploy-staging — needs: [verify], environment: staging (no reviewer: -# the dispatch itself is the deliberate act). Runs the +# the run itself is the deliberate act). Runs the # same script with TEQO_ENV=staging (teqo-staging, # 127.0.0.1:1314). # deploy-production — needs: [deploy-staging], environment: production # (required reviewer fsolla, configured in the repo). # A red staging fail-closes production. Same script, # TEQO_ENV=production (teqo-1313, 127.0.0.1:1313). +# requeue — hosted, after deploy-staging: re-dispatches when main +# advanced (OPS104 trigger 2). # # Both deploy jobs run on the GitHub self-hosted runner ON the homeserver # (labels self-hosted + homeserver): the script executes locally — no SSH -# hop, no workstation involvement. Deliberately wired ONLY here (manual -# dispatch), never in ci-pr.yml: a self-hosted job is code execution on the -# production machine, and only an explicit dispatch with the full verify -# suite green can reach it. The hosted job never reaches the homeserver; the -# self-hosted job never counts hosted minutes. +# hop, no workstation involvement. Never moved into ci-pr.yml: a self-hosted +# job is code execution on the production machine, and only a run with the +# full verify suite green can reach it. The hosted job never reaches the +# homeserver; the self-hosted job never counts hosted minutes. # # The environment-specific builds CANNOT leave the homeserver (migrate # pre-build on the env DB — OPS66 — and the compose network / local registry -# localhost:5000), which is exactly why the hosted job only verifies and the -# self-hosted jobs only build + deploy. Deploy is idempotent ("already -# deployed" guard in deploy-homeserver.sh) and serialized by flock per -# environment; the `concurrency` group below serializes the two shared +# localhost:5000), which is exactly why the hosted jobs only verify/decide +# and the self-hosted jobs only build + deploy. Deploy is idempotent +# ("already deployed" guard in deploy-homeserver.sh) and serialized by flock +# per environment; the `concurrency` group below serializes the two shared # homeserver compose/workspace consumers across runs without cancelling an # in-flight deploy (the staged `needs` chain already serializes them inside # one run). # # The deploy jobs fetch the repo from github.com (public) — TEQO_REPO_URL in -# deploy-homeserver.sh now defaults there; the script runs to completion for -# the dispatched SHA even if main advances during verify (OPS102 — publishing -# is a deliberate operator act). +# deploy-homeserver.sh defaults there; the script runs to completion for the +# SHA of the run even if main advances during verify (OPS102). +# +# OPS104 supersedes the OPS102 "manual-only" anti-goal: only the START of +# the run is automatic. Production still publishes exclusively through the +# `production` environment approval; there is no auto-approve, no auto-retry +# and no cancel-old-runs automation. -name: Deploy (manual) +name: Deploy on: + push: + branches: [main] workflow_dispatch: permissions: @@ -54,7 +72,30 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: + preflight: + # OPS104 trigger 1 guard: skip this push when another deploy run is + # already queued/in_progress (a run `waiting` on the production approval + # does NOT count). Manual dispatches skip the guard (the step is + # push-only) and the output default keeps the dispatch path flowing. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }} + steps: + - uses: actions/checkout@v5 + - name: Decide whether this push should deploy + id: guard + if: github.event_name == 'push' + run: node scripts/deploy-preflight.mjs + env: + GITHUB_TOKEN: ${{ github.token }} + verify: + needs: [preflight] + if: needs.preflight.outputs.should_deploy == 'true' runs-on: ubuntu-latest timeout-minutes: 50 services: @@ -160,10 +201,10 @@ jobs: deploy-staging: name: deploy staging (teqo-staging) - # OPS103: same SHA, same verify, own environment — no reviewer (the - # manual dispatch is the deliberate act); production is the approval that - # matters. The `main` guard makes a dispatch from a branch/tag a no-op - # for deploys (the runbook's dispatch ref is always `main`). + # OPS103 (+OPS104): same SHA, same verify, own environment — no reviewer + # (the run was started deliberately: push to main or manual dispatch); + # production is the approval that matters. The `main` guard makes a + # dispatch from a branch/tag a no-op for deploys. needs: [verify] if: needs.verify.result == 'success' && github.ref == 'refs/heads/main' environment: staging @@ -185,16 +226,39 @@ jobs: - name: Deploy teqo-staging on the homeserver run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA" + requeue: + name: requeue when main advanced + # OPS104 trigger 2: after a green staging, dispatch a fresh run from + # main's new head when a merge landed mid-flight. Hosted and OUTSIDE the + # `deploy-homeserver` group: a run `waiting` on the production approval + # holds that group and must not block the requeue. `actions: write` is the + # documented anti-recursion exception — workflow_dispatch events DO create + # runs for the built-in GITHUB_TOKEN (no secret needed). + needs: [deploy-staging] + if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@v5 + - name: Requeue when main advanced during the run + run: node scripts/deploy-requeue.mjs + env: + GITHUB_TOKEN: ${{ github.token }} + deploy-production: name: deploy production (teqo-1313) - # OPS53 + OPS71 + OPS103. Runs on the GitHub self-hosted runner ON the - # homeserver (labels self-hosted + homeserver): the script executes - # locally — no SSH hop, no workstation involvement. Deliberately wired - # ONLY here (manual dispatch), never in ci-pr.yml: a self-hosted job is - # code execution on the production machine, and only an explicit dispatch - # with the full verify suite green can reach it. `needs: [deploy-staging]` - # makes a red staging fail-close production; the environment `production` - # (required reviewer, configured in the repo) is the approval gate. + # OPS53 + OPS71 + OPS103 + OPS104. Runs on the GitHub self-hosted runner + # ON the homeserver (labels self-hosted + homeserver): the script executes + # locally — no SSH hop, no workstation involvement. Never wired in + # ci-pr.yml: a self-hosted job is code execution on the production + # machine, and only a run with the full verify suite green can reach it + # (the automatic start and the manual dispatch share the same gate). + # `needs: [deploy-staging]` makes a red staging fail-close production; the + # environment `production` (required reviewer, configured in the repo) is + # the approval gate — and the ONLY one. needs: [deploy-staging] if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main' environment: production diff --git a/AGENTS-infra.md b/AGENTS-infra.md index d7a43b34..4678d3cd 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -2,7 +2,7 @@ ## Deploy & CI -Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with three jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only. +Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: a merge to `main` starts the deploy run automatically (OPS104 supersedes the OPS102 manual-only anti-goal; `workflow_dispatch` stays as the manual escape) — the verifier IS the deploy's `verify` job. The deploy workflow has five jobs: `preflight` (hosted; `scripts/deploy-preflight.mjs` skips the run when another deploy is `queued`/`in_progress` — a `waiting` production approval does not count) → `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `requeue` (hosted, outside the deploy concurrency group; `scripts/deploy-requeue.mjs` re-dispatches a fresh run when `main` advanced — OPS104) and `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only. **Guarding `pnpm push` (OPS24+):** every PR push goes through `scripts/git-push.mjs`, which runs `ensure-repo-deps` (install + Husky in worktrees — OPS51), then `gate:push` (the pre-push hook), which chains `gate:ci` — a serialized mirror of the CI `checks` job (**without e2e**: OPS72 moved e2e to an explicit skill step) — plus the `docs-guards` (changelog entries additions-only + frozen HISTORY snapshot + uncommitted regenerable aggregate — OPS63/OPS85; conflict markers; the rewrite escape stays in CI only). The hook blocks `git push` on failure; escape: `git push --no-verify` (WIP only). There is no YAML `push` gate — the hook is the gate. **CI red on your PR is yours**, even pre-existing or infra — "out of feature scope" is not a stop condition (policy "Dono do PR, dono do CI" in `docs/AGENT-OPS.md`). @@ -18,7 +18,7 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the 1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use the homeserver prod DB for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt` / `teqo_wt_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names. 2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`. -3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72 + OPS86):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- ` ou `--no-deps --project=`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (via `ci-scope.mjs` — **nunca `full`**): diff mapeado roda `selected`, diff **high-risk** (coleções/globals/migrações/lockfile/harness) roda o **`curated`** — conjunto fixo de 5 specs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`, congelado por pin em `e2eAffectedManifest.unit.spec.ts`) — **nunca zero e2e no PR**; arquivo de **área de risco sem entry no manifesto** (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica **`unmapped-risk` e o PR FALHA** listando os arquivos (fail-closed; resolver = adicionar entry no `E2E_AFFECTED_MANIFEST`); src/ não-mapeado não-risco roda o smoke `campaignHomeActions`. Os passos unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`: seleção vazia cai em **suíte full** — nunca "0 testes, verde". O e2e **full** roda no `verify` do deploy manual (gateia merge e deploy — rede de segurança final antes de publicar); localmente, diff high-risk segue rodando full no `pnpm test:e2e:affected`. +3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72 + OPS86):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- ` ou `--no-deps --project=`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (via `ci-scope.mjs` — **nunca `full`**): diff mapeado roda `selected`, diff **high-risk** (coleções/globals/migrações/lockfile/harness) roda o **`curated`** — conjunto fixo de 5 specs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`, congelado por pin em `e2eAffectedManifest.unit.spec.ts`) — **nunca zero e2e no PR**; arquivo de **área de risco sem entry no manifesto** (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica **`unmapped-risk` e o PR FALHA** listando os arquivos (fail-closed; resolver = adicionar entry no `E2E_AFFECTED_MANIFEST`); src/ não-mapeado não-risco roda o smoke `campaignHomeActions`. Os passos unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`: seleção vazia cai em **suíte full** — nunca "0 testes, verde". O e2e **full** roda no `verify` do deploy (o run nasce no merge — OPS104; a produção só publica com o approve do reviewer — gateia merge e deploy, rede de segurança final antes de publicar); localmente, diff high-risk segue rodando full no `pnpm test:e2e:affected`. - Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w `) before deleting. - Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there. 4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change. diff --git a/AGENTS.md b/AGENTS.md index 52f69ba6..1b162db4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/curated, skips via `ci-scope.mjs`; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechado `unmapped-risk`, e unit/int `changed` com seleção vazia cai em full via `vitest-changed-or-full.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with three jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, intocado e rodado uma vez por commit) → `deploy-staging` (`environment: staging`, sem reviewer) → `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`; staging vermelho não deixa produção rodar — OPS103): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging vivem só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from the environment's env file), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/curated, skips via `ci-scope.mjs`; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechado `unmapped-risk`, e unit/int `changed` com seleção vazia cai em full via `vitest-changed-or-full.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: a merge to `main` starts the deploy run automatically (OPS104 supersedes the OPS102 manual-only anti-goal; `workflow_dispatch` stays as the manual escape) — the verifier IS the deploy's `verify` job. The workflow (`.github/workflows/deploy.yml`) now has five jobs: `preflight` (hosted; skips the run when another deploy is `queued`/`in_progress` — a `waiting` production approval does not count) → `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, intocado e rodado uma vez por commit) → `deploy-staging` (`environment: staging`, sem reviewer) → `requeue` (hosted, outside the deploy concurrency group; re-dispatches a fresh run when `main` advanced during the run — OPS104) and `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`; staging vermelho não deixa produção rodar — OPS103): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging vivem só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from the environment's env file), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/README.md b/README.md index 07121fc4..402cb4de 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ > 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues, o código, os PRs e o CI vivem no GitHub** (`github.com/fsolla/teqo`). > 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`). > 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde. -> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge. +> 4. Deploy **começa automático**: o merge em `main` dispara o `deploy.yml` (suíte full no `verify`) → staging publica sozinho → **produção só com o approve do reviewer** no environment `production` (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout; o staging velho se atualiza via requeue (OPS104). `workflow_dispatch` segue como escape manual. > 5. Secrets humanos (uma vez): `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para os scripts de agente/PR (`pnpm issue`/`agent:*`, `github-pr.mjs`); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`). > > Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`. > Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`. -> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback. +> **Agente faz sozinho:** claim → implementar → PR → main (o merge já inicia o deploy de staging). **Só humano:** aprovar a produção no environment do run, envs do homeserver, branch protection, runbook de rollback. > Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`. Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil. @@ -97,7 +97,7 @@ To change the schema: 3. Review and commit the generated files in `src/migrations/` (both `.ts` and `.json`, plus `index.ts`). 4. Apply it locally: `pnpm migrate` (check status anytime with `pnpm migrate:status`). -**Deploying to production:** merges to `main` with production changes are deployed by the `deploy` job of `ci.yml` (windowed — see `docs/AGENT-OPS.md`); the remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`. +**Deploying to production:** merges to `main` trigger `deploy.yml` automatically (OPS104) — the `verify` job runs the full suite, `deploy-staging` publishes on its own, and `deploy-production` waits for the `production` environment approval. The remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`. ### Running tests diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index cd207a12..051fad08 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -1,6 +1,6 @@ # AGENT-OPS — operação do paradigma de agentes paralelos -Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71). +Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente entrega em `main`, deploy **começa automático** no merge e a produção continua **manual** (reviewer) com verificação full (OPS71 + OPS104). ## Ambientes de dados (test ladder — nunca misturar) @@ -10,19 +10,19 @@ Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente | **Staging** (`teqo_staging`, homeserver) | sintético (`db:seed:minimal`) | job `deploy-staging` do `deploy.yml` (OPS103); inspeção humana antes da aprovação de produção | | **Prod** (`teqo_1313`, homeserver) | real | job `deploy-production` do `deploy.yml` (OPS53 + OPS71 + OPS103) | -Agentes **nunca** recebem `DATABASE_URL` de prod/staging e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml` — o staging da OPS103 é **alvo de promoção de um SHA de `main`** (dispatch manual → staging → aprovação → produção), nunca destino de PR. +Agentes **nunca** recebem `DATABASE_URL` de prod/staging e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml` — o staging da OPS103 é **alvo de promoção de um SHA de `main`** (merge em `main` ou `workflow_dispatch` → staging → aprovação → produção), nunca destino de PR. ## Fluxo ```text claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase) -main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → deploy-staging → aprovação do environment production → deploy-production (OPS53 + OPS103) +main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → preflight (pula se já há deploy ativo) → verify full verde → deploy-staging → aprovação do environment production → deploy-production; requeue re-despacha se main andou no meio (OPS53 + OPS103 + OPS104) ``` **Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do GitHub (`github.com/fsolla/teqo/issues`). - **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net. -- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`). +- **Só humano:** aprovar o environment `production` no run (o único gate de publicação — OPS104 nunca auto-aprova), envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`). ### Dono do PR, dono do CI @@ -76,16 +76,16 @@ Arquivos de "lista" que N agentes escrevem em paralelo (plans, migrações, cata ## CI por alvo (GitHub Actions, OPS71) -| Workflow | Trigger | Banco | Passos | -| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) | -| `deploy.yml` | **`workflow_dispatch` manual** | 1× service Postgres 17 | **Três jobs (OPS103):** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy-staging` (`needs: [verify]`, `environment: staging`, `TEQO_ENV=staging`) → `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer, `TEQO_ENV=production`); ambos os deploys rodam em `runs-on: [self-hosted, homeserver]` e executam `scripts/deploy-homeserver.sh ` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do dispatch mesmo se `main` andar durante o verify — OPS102; idempotente; um `flock` compartilhado + `concurrency: deploy-homeserver` com `queue: max`; migrator→migrate→runner→rollout→smoke) — staging vermelho fail-closa produção | -| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) | -| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) | -| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip | -| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool | +| Workflow | Trigger | Banco | Passos | +| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) | +| `deploy.yml` | **`push` em `main` (automático, OPS104) + `workflow_dispatch`** | 1× service Postgres 17 | **Cinco jobs (OPS103 + OPS104):** `preflight` (hosted; `scripts/deploy-preflight.mjs` pula o run se já há deploy `queued`/`in_progress` — `waiting` de approval não conta; dispatch manual pula o guard) → `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy-staging` (`needs: [verify]`, `environment: staging`, `TEQO_ENV=staging`) → `requeue` (hosted, **fora** do grupo `deploy-homeserver`; `scripts/deploy-requeue.mjs` re-despacha `deploy.yml` se o head de `main` andou — OPS104) e `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer, `TEQO_ENV=production`); ambos os deploys rodam em `runs-on: [self-hosted, homeserver]` e executam `scripts/deploy-homeserver.sh ` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do run mesmo se `main` andar durante o verify — OPS102; idempotente; um `flock` compartilhado + `concurrency: deploy-homeserver` com `queue: max`; migrator→migrate→runner→rollout→smoke) — staging vermelho fail-closa produção; nada publica sem o approve do reviewer | +| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) | +| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip | +| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool | -`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full. +`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do `deploy.yml` (iniciado por push em `main` ou dispatch manual) roda a suíte full. Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`. @@ -102,13 +102,13 @@ Local: `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para `node scrip ### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual -O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742). +O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742). Desde a OPS104 o mesmo invariante também governa o deploy automático: um merge via `GITHUB_TOKEN` não criaria o run de `push` do `deploy.yml` — o `AUTOMERGE_PAT` é o que mantém trigger 1 (e os flips) funcionando. **Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). O flip **fecha** a Issue (`PATCH state=closed` — o `closed` do GitHub por keyword pode fechar, mas o flip explicita labels `done`/`in-prod` + close para o tracker GitHub). Falha em qualquer passo = job vermelho (OPS61, nunca mentir). Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente). -Deploy (OPS53 + OPS71 + OPS103): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full, **intocado** — verifica o commit uma vez) roda **antes** dos deploys: `deploy-staging` (`environment: staging`, sem reviewer) e então `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer; staging vermelho nunca deixa produção rodar). Os dois executam `scripts/deploy-homeserver.sh ` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`). O script: flock por ambiente → guard "already deployed" (revision do container do ambiente) → build do migrator (BuildKit, secrets do env file do ambiente) → push/tag em `localhost:5000` → swap dos tags no compose (backup; sed ancorado ao serviço do ambiente) → **migrations** (`teqo-1313-migrate`/`teqo-staging-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do dispatch mesmo se `main` avançar durante o verify (o antigo guard de stale run era da era automática); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas, bootstrap do staging): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy). +Deploy (OPS53 + OPS71 + OPS103 + OPS104): **começa automático** — um `push` em `main` dispara o `deploy.yml` (o job `preflight` pula se já há deploy `queued`/`in_progress`; um run `waiting` de approval **não** conta) e `workflow_dispatch` segue como escape manual. O job `verify` (hosted, suíte full incl. e2e full, **intocado** — verifica o commit uma vez) roda **antes** dos deploys: `deploy-staging` (`environment: staging`, sem reviewer) e então `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer; staging vermelho nunca deixa produção rodar). Depois do staging verde, o job hosted `requeue` compara o head de `main` com o SHA do run e re-despacha um run novo se `main` andou no meio — **fora** do grupo de concurrency, para um approval pendente não bloquear a convergência. Os dois deploys executam `scripts/deploy-homeserver.sh ` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`). O script: flock por ambiente → guard "already deployed" (revision do container do ambiente) → build do migrator (BuildKit, secrets do env file do ambiente) → push/tag em `localhost:5000` → swap dos tags no compose (backup; sed ancorado ao serviço do ambiente) → **migrations** (`teqo-1313-migrate`/`teqo-staging-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do run mesmo se `main` avançar durante o verify; a convergência é do `requeue` da OPS104 (o antigo guard de stale run segue morto); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas, bootstrap do staging): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main (`ci.yml`) foi eliminado no OPS71 — a rede de regressão de `main` agora é o `verify` full do próprio `deploy.yml`, que nasce no merge (OPS104 supersede o anti-goal "manual-only" da OPS102: automatiza-se o **início** do run, nunca o approve de produção). ## Cursor Cloud diff --git a/docs/TESTING.md b/docs/TESTING.md index 38d3b48b..cacd835d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -10,7 +10,7 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse **Level preference (pyramid):** unit first (pure logic) → integration for Payload/DB boundaries → e2e only when the flow has real benefit the lower layers don't cover (critical user path, UX/navigation, URL contract). Canonical definition: `test-driven-development` skill. -CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against ONE Postgres 17 service, with a deterministic **`pnpm db:reset`** (drop schema → migrate → seed:minimal) between the int phase and the build/e2e phase (OPS88). Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch). +CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against ONE Postgres 17 service, with a deterministic **`pnpm db:reset`** (drop schema → migrate → seed:minimal) between the int phase and the build/e2e phase (OPS88). Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing — the deploy run now starts on merge to `main` (OPS104), and production still waits for the `production` environment approval. **Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode). diff --git a/docs/changelog/2026-09-14-ops104.md b/docs/changelog/2026-09-14-ops104.md new file mode 100644 index 00000000..64732fa6 --- /dev/null +++ b/docs/changelog/2026-09-14-ops104.md @@ -0,0 +1 @@ +**OPS104 — Disparo automático do deploy pós-merge (staging automático, produção com review) (2026-09-14):** publicar deixou de depender de alguém lembrar do dispatch — o `deploy.yml` agora nasce no merge e converge sozinho, sem nunca auto-aprovar produção. **Trigger 1:** `push` em `main` somado ao `workflow_dispatch` (escape manual preservado); o job hosted `preflight` (`scripts/deploy-preflight.mjs`) pula o run quando já existe outro deploy `queued`/`in_progress` — um run `waiting` de approval **não** conta (merge nessa janela começa um run novo e os dois ficam pendentes para o humano escolher), e falha de API é fail-open (o guard é dedupe, não gate). **Trigger 2:** depois do `deploy-staging` verde, o job hosted `requeue` (`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` — um approval pendente não pode bloquear a convergência) lê o head de `main` e re-despacha o workflow se um merge chegou durante o verify/staging; falha é fail-red. A lógica de decisão é pura (`scripts/lib/deploy-trigger.mjs`; `ACTIVE_RUN_STATUSES`, `decidePreflight`, `decideRequeue`) e o client `scripts/lib/github-api.mjs` ganhou `listWorkflowRuns`/`getBranchHead`; dispatch usa o `GITHUB_TOKEN` nativo (`workflow_dispatch` é a exceção documentada da anti-recursão — nenhum secret novo, nenhum PAT). O fluxo interno do deploy não mudou: `verify` full intocado (nada publica sem verde), environments intocados (`staging` sem reviewer, `production` com required reviewer), `concurrency: deploy-homeserver` (`cancel-in-progress: false`, `queue: max`) segue só nos jobs de deploy, nenhum auto-retry/cancelamento de runs antigos, nenhum workflow gêmeo e `scripts/deploy-homeserver.sh` intocado (pin `deployScript.unit.spec.ts` verde sem edição). O pin unitário do manual-only virou o pin do novo contrato (`ciSkipInvariants.unit.spec.ts`: push em `main` + guard + dispatch + requeue), com `deployTrigger.unit.spec.ts` novo e casos novos em `githubApi.unit.spec.ts`. Riscos operacionais documentados no runbook (`docs/ops/teqo-1313-deploy.md`): um approval pendente segura o grupo de concurrency (staging de runs novos espera) e um merge durante `deploy-production in_progress` só converge no próximo merge/dispatch — ambos com gatilho de revisitação. O anti-goal "manual-only" da OPS102 fica superseded: automatiza-se o **início** do run, nunca o approve de produção. Docs vivos realinhados (`AGENTS.md`, `AGENTS-infra.md`, `docs/AGENT-OPS.md`, `README.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/rules/engineering-standards.mdc`, skill `bug-fix`, `docs/TESTING.md`). diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 1b6c310f..ae899136 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1,31 +1,48 @@ -# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 — dispatch manual) +# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 — início automático, produção com review) -O deploy é uma **action manual** (`workflow_dispatch` no GitHub Actions, -`.github/workflows/deploy.yml`): mergir em `main` **não** publica nada. O -operador dispara o deploy quando decide publicar; antes de tocar o homeserver, -o job `verify` (hosted) roda a suíte **full** (incl. e2e full) **uma vez** e -gateia os dois alvos: `deploy-staging` → aprovação do environment `production` -→ `deploy-production` (OPS103). +Desde a OPS104 o deploy **começa sozinho**: todo merge em `main` (evento +`push`) inicia o run do `deploy.yml` no head, e um segundo gatilho re-despacha +um run novo se `main` andar durante o verify/staging. O que continua manual é +a **publicação em produção**: o job `deploy-production` espera a aprovação do +environment `production`. Antes de tocar o homeserver, o job `verify` (hosted) +roda a suíte **full** (incl. e2e full) **uma vez** e gateia os dois alvos: +`deploy-staging` → aprovação do environment `production` → +`deploy-production` (OPS103). ## Gatilho e fluxo -1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`). -2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips — +1. **Início automático (OPS104):** merge em `main` → o run nasce no head; o + job `preflight` (hosted) pula o run quando já existe outro deploy + `queued`/`in_progress`. Um run aguardando aprovação de produção (`waiting`) + **não** conta: um merge nessa janela começa um run novo e os dois ficam + pendentes (o humano aprova qual promover). `workflow_dispatch` (GitHub → + Actions → **Deploy** → Run workflow, ref: `main`) segue como escape manual e + **não** passa pelo guard. +2. Job `preflight` (hosted, `scripts/deploy-preflight.mjs`): consulta os runs + do próprio workflow; sem deploy ativo → segue. Falha de API é fail-open + (o guard é dedupe, não gate de segurança). +3. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips — check-test-locations → lint → format → typecheck → knip → cycles → unit → - int (migrate+seed nos services) → build → e2e full. **Intocado pela - OPS103**: verifica o commit, não o alvo. -3. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`, + int (migrate+seed nos services) → build → e2e full. **Intocado pelas + OPS103/OPS104**: verifica o commit, não o alvo. +4. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`, `environment: staging`, sem reviewer) roda no runner self-hosted e executa `TEQO_ENV=staging bash scripts/deploy-homeserver.sh ` — publica o SHA em `staging.jorgesolla1313.com.br` (container `teqo-staging`, `127.0.0.1:1314`). -4. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`, +5. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`, `environment: production`) **aguarda a aprovação do reviewer** e então executa `TEQO_ENV=production bash scripts/deploy-homeserver.sh ` — publica o **mesmo SHA** em produção (`teqo-1313`, `127.0.0.1:1313`). Staging vermelho = produção nunca roda; a corrida entre os dois é serializada pela `concurrency: deploy-homeserver`. -5. O script, no **homeserver**: `flock` único (compose, workspace e registry +6. **Requeue (OPS104):** com o staging verde, o job hosted `requeue` + (`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` — + um run aguardando aprovação não pode bloquear o requeue) compara o head de + `main` com o SHA do run; se `main` andou durante o verify/staging, dispara + um run novo no head novo. Falha aqui é fail-red (job vermelho): staging + velho nunca fica silencioso. +7. O script, no **homeserver**: `flock` único (compose, workspace e registry são compartilhados — um deploy por vez no host; o `concurrency` do workflow serializa os runs e o lock cobre invocação manual; desde a OPS102 o run vai até o fim com o SHA do dispatch mesmo se `main` avançar durante o @@ -47,15 +64,17 @@ gateia os dois alvos: `deploy-staging` → aprovação do environment `productio teqo-` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do ambiente). -6. Falha = job vermelho; nada é publicado pela metade (rollback automático +8. Falha = job vermelho; nada é publicado pela metade (rollback automático após o swap, **só do ambiente que falhou**). -**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o -deploy manual e confira no log do job `deploy-production` o `set-url` do -workspace (origem Forgejo → GitHub), o `deployed_sha` lido do container e o -guard "already deployed" num segundo dispatch do mesmo SHA. Na OPS103, o -caminho de estreia é o mesmo dispatch: staging primeiro (bootstrap abaixo), -inspeção humana, aprovação, produção. +**Primeiro deploy (verificação ao vivo):** o caminho de estreia do OPS104 é +mergear em `main` e observar o run nascer sozinho: log do `preflight` +(`should_deploy=true`), `verify` verde, staging publicado e `deploy-production` +aguardando aprovação. Para o cutover OPS71, confira no log do job +`deploy-production` o `set-url` do workspace (origem Forgejo → GitHub), o +`deployed_sha` lido do container e o guard "already deployed" num segundo run +do mesmo SHA. Na OPS103, staging primeiro (bootstrap abaixo), inspeção humana, +aprovação, produção. ## Onde roda cada coisa @@ -75,7 +94,7 @@ secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e ## Staging (OPS103) Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes -de produção. Mesmo dispatch, mesmo `verify`, mesmo script — o que muda é +de produção. Mesmo run, mesmo `verify`, mesmo script — o que muda é `TEQO_ENV=staging` e o environment `staging` (sem reviewer; a aprovação que importa é a de `production`). @@ -222,8 +241,10 @@ a correção se re-mergeia e o próximo deploy completo publica. | Sintoma | Causa | Tratamento | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Não re-dispatchar SHA antigo; se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se o deploy ganhar frequência/automação ou se uma regressão fora-de-ordem acontecer | -| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | +| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue | +| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | +| Merge durante aprovação pendente de produção: staging do run novo fica `pending` | O job `waiting` de approval **segura o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | O run novo já nasceu (o guard não conta `waiting`); aprovar/rejeitar o run pendente libera o grupo. Nada publica sem reviewer. Revisitar se aprovações passarem a demorar sistematicamente → avaliar grupos/locks por ambiente (o lock compartilhado é decisão da OPS103) | +| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard | | Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys | | Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado | | `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` | @@ -261,12 +282,14 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**. - Os jobs `deploy-staging`/`deploy-production` usam `runs-on: [self-hosted, homeserver]` — executam como o usuário do runner no - homeserver. Estão **apenas** no `deploy.yml` (dispatch manual), nunca no - `ci-pr.yml`; o hosted (`verify`) não tem acesso ao homeserver e o self-hosted - não recebe secrets de produção (as envs vivem no próprio homeserver, - inclusive as de staging — nunca como GitHub secrets). `main` só anda por PR - mergeado com CI verde; o deploy só roda com `verify` full verde. Fork-PRs - não rodam CI (same-repo gate + setting do repo). + homeserver. Estão **apenas** no `deploy.yml` (início automático por `push` + em `main` ou `workflow_dispatch`; ambos passam pelo mesmo `verify` full), + nunca no `ci-pr.yml`; o hosted (`preflight`/`verify`/`requeue`) não tem + acesso ao homeserver e o self-hosted não recebe secrets de produção (as + envs vivem no próprio homeserver, inclusive as de staging — nunca como + GitHub secrets). `main` só anda por PR mergeado com CI verde; o deploy só + roda com `verify` full verde. Fork-PRs não rodam CI (same-repo gate + + setting do repo). - Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` / build-secrets; envs só no homeserver. - Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex — @@ -286,9 +309,10 @@ fallback e não foi removida por esta entrega. "Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI. 3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600): `GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`. -4. Deploy normal (`workflow_dispatch` do `deploy.yml`). O callback monta o - redirect a partir de `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com - o URI registrado no passo 1. +4. Deploy normal (`deploy.yml` — o merge em `main` já dispara; `workflow_dispatch` + segue como escape). O callback monta o redirect a partir de + `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com o URI registrado no + passo 1. Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o @@ -502,8 +526,9 @@ Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "spee ## Referências - `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`) -- `.github/workflows/deploy.yml` — verify → deploy-staging → deploy-production (OPS103) +- `.github/workflows/deploy.yml` — preflight → verify → deploy-staging → requeue/deploy-production (OPS103/OPS104) - `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo) - `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover - `docs/plans/ops103-staging-homeserver*.md` — staging com verify único e aprovações separadas +- `docs/plans/ops104-disparo-automatico-do-deploy*.md` — início automático + requeue (staging automático, produção com review) - infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy" diff --git a/docs/plans/ops104-disparo-automatico-do-deploy-impl.md b/docs/plans/ops104-disparo-automatico-do-deploy-impl.md new file mode 100644 index 00000000..86b4a6ef --- /dev/null +++ b/docs/plans/ops104-disparo-automatico-do-deploy-impl.md @@ -0,0 +1,166 @@ +# Impl: OPS104 — Disparo automático do deploy pós-merge (staging automático, produção com review) + +Status: aprovado (gate humano 2026-09-14) +Atualizado em: 2026-09-14 +Issue: #980 +Intenção: docs/plans/ops104-disparo-automatico-do-deploy.md +Appetite restante: ~1 dia eng (herdado; cortes explícitos em "Rabbit holes / Não escopo") + +## Leitura da intenção + +- **Outcome:** todo merge em `main` sem deploy ativo inicia sozinho o `deploy.yml` no head; `verify` full → `deploy-staging` automático; ao terminar o staging, se `main` andou, um run novo nasce no head novo; produção continua **somente** com aprovação humana do environment `production`. Dispatch manual segue como escape. +- **O que NÃO negociar:** nunca auto-aprovar/publicar produção; nunca atalhar o `verify` full; nunca um workflow/script gêmeo (o dono é o `deploy.yml`); nunca tocar `ci-pr.yml`, o job `verify`, os guards de banco local, secrets ou o `scripts/deploy-homeserver.sh` (pinado por `deployScript.unit.spec.ts`); nunca reintroduzir o stale-run guard (OPS102); `concurrency: deploy-homeserver` intocado (`cancel-in-progress: false`, `queue: max`) e só nos jobs de deploy; `waiting` (aprovação pendente) **não** conta como run ativo no guard; sem retry/auto-heal de vermelho; sem cancelar runs antigos. +- **O que reavaliar (hipóteses da intenção):** + - `tests/unit/ciSkipInvariants.unit.spec.ts` l.113-114 (lê YAML cru): confirmado — `toContain('workflow_dispatch:')` e `not.toMatch(/^\s*push:/m)` quebram ao adicionar `push`; o teste l.124-150 é additive-safe (jobs/chaves novas passam; o literal `needs: [deploy-staging]` não pode mudar). O pin do manual-only será reescrito nesta entrega. + - `deployScript.unit.spec.ts` não muda (o script é congelado; `git ls-remote` segue banido **dentro dele**). + - **Achado novo de engenharia (não antecipado na intenção):** no GitHub, um job aguardando aprovação de environment **segura** o grupo `concurrency` (comportamento documentado na community discussion #67607 — a avaliação de concurrency acontece antes do approval). Consequência: enquanto uma aprovação de produção espera, o `deploy-staging` de um run novo fica `pending` atrás dela. O aceite de produto continua satisfeito (o run novo **nasce**; `waiting` não bloqueia o trigger 1; nada publica sem reviewer), mas "staging atualiza sozinho" pausa durante uma aprovação pendente. Isso vira risco documentado (R2), não redesign — o grupo compartilhado é decisão da OPS103/intenção. + - Duplicata do trigger 2: o run de `push` que o preflight skipou termina `completed` (jobs skipados) — indistinguível de "já deployado". Dedupe por "existe run no head" mascararia um SHA não publicado; então **não** há dedupe (D4). + +## Abordagem recomendada + +```mermaid +flowchart LR + P[merge/push em main] --> PF{"preflight
existe run queued/in_progress
(excluindo o próprio)?"} + M[workflow_dispatch] --> V + PF -- "sim" --> N["run nasce e para
(verify/deploys skipados)"] + PF -- "não" --> V["verify full (hosted)"] + V --> S["deploy-staging
(self-hosted, grupo deploy-homeserver)"] + S --> R{"head de main == SHA do run?"} + R -- "não" --> D["requeue (hosted):
dispatch deploy.yml ref main"] + D -. "novo run" .-> V + R -- "sim" --> W["deploy-production
aguarda reviewer"] + W -- "approve" --> PR["deploy-production
(self-hosted)"] +``` + +**Opções consideradas:** A) trigger 1 = `push` no próprio `deploy.yml` + job de preflight que pula o run quando já há deploy ativo, e trigger 2 = job hosted de requeue após `deploy-staging` (recomendada) | B) workflow gêmeo "dispatcher" separado do `deploy.yml` | C) só `concurrency` no `deploy.yml` (sem guard nem requeue) | D) trigger por `pull_request: closed` em vez de `push`. +**Recomendação:** **A** — um dono só (sem drift), o pin unitário do manual-only passa a testar o guard, o dispatch manual continua no mesmo arquivo, e o requeue hosted não entra no grupo de deploy (não pode ser bloqueado pela aprovação pendente). É exatamente o decidido no gate de produto. +**Rejeitadas:** B porque twin cria drift e um segundo lugar onde o "run ativo" escapa do guard (anti-goal explícito); C porque `concurrency` enfileira, não pula — todo push rodaria `verify` full de novo e SHAs intermediários deployariam em ordem (contra "nada nasce"); D porque o merge via `GITHUB_TOKEN` (anti-recursão) não cria run de `pull_request` `closed` (mesma classe do OPS71-FLIP) e o contrato `push` é o decidido. + +### Decisões de engenharia (caras de reverter) + +#### D1 — Como detectar run ativo (guard do trigger 1) + +- **Opções:** A) REST `GET /repos/{owner}/{repo}/actions/workflows/deploy.yml/runs` via helper node em `scripts/lib/github-api.mjs`; B) `gh run list` inline no YAML; C) `concurrency` puro (`queue: max`) sem guard; D) checar check-runs/status do commit. +- **Recomendação:** **A** — dois `GET` com `status=queued` e `status=in_progress`, `branch=main`, `per_page=100`; filtra `run.id != github.run_id` e decide. Usa o client existente (retry OPS67 para GET/5xx), testável com `fetchImpl`, sem dependência de CLI. +- **Rejeitadas:** B porque parseia texto de `gh` (o repo tirou `gh` dos scripts na OPS50), é frágil e não tem seam de teste; C porque não pula o run — só enfileira (o aceite é "nada nasce", e verify full a cada push é o que se quer evitar); D porque run `queued` não tem check-run no commit e um job `waiting` de aprovação (que **não** conta) é indistinguível de `in_progress` por check-run. + +#### D2 — Como detectar o head novo de `main` (trigger 2) + +- **Opções:** A) REST `GET /repos/{owner}/{repo}/git/ref/heads/main` via `getBranchHead`; B) `git ls-remote https://github.com/fsolla/teqo.git refs/heads/main`; C) `git fetch` + `git rev-parse origin/main`. +- **Recomendação:** **A** — uma chamada REST no mesmo client, testável, sem checkout extra; o job hosted (ubuntu-latest) nem depende de `git`. +- **Rejeitadas:** B porque, embora banido apenas **dentro** do `deploy-homeserver.sh` (pin do OPS102), repetir o padrão do stale-run guard no YAML confunde leitura/runbook e não tem teste unitário sem subprocess; C porque exige checkout+fetch pesado e adiciona estado local para uma comparação de SHA. + +#### D3 — Onde mora a lógica reutilizável + +- **Opções:** A) API em `scripts/lib/github-api.mjs` (`listWorkflowRuns`, `getBranchHead`) + decisões puras em `scripts/lib/deploy-trigger.mjs` (`decidePreflight`, `decideRequeue`) + dois CLIs finos (`scripts/deploy-preflight.mjs`, `scripts/deploy-requeue.mjs`); B) tudo inline no YAML com `curl`+`jq`; C) um script monolítico novo dono dos dois lados. +- **Recomendação:** **A** — segue o padrão já estabelecido (`scripts/lib/github-pr-flow.mjs` exporta `decideAutomergeAction` e o CLI `scripts/github-pr-automerge.mjs` só faz o wiring; o client `github-api.mjs` é o módulo profundo de HTTP). Decisões puras testáveis sem fetch; CLIs sem lógica. +- **Rejeitadas:** B porque YAML não é testável, duplicaria a lógica entre os dois jobs e `jq` vira contrato de string; C porque mistura HTTP + decisão num arquivo sem seam (testar exigiria mock global) e o entry do knip executaria side effects. + +#### D4 — Dedupe do trigger 2 (run novo vs run de push skipado) + +- **Opções:** A) aceitar a duplicata; B) antes de dispatchar, checar se já existe run no head novo e pular; C) cancelar runs antigos/duplicados. +- **Recomendação:** **A** — a duplicata é inerte (o run de push skipado não executou verify/deploy) e a janela de corrida é de segundos; o dispatch é idempotente (`already deployed` verde no script para o mesmo SHA) e converge (o run novo compara head==SHA no fim e para). +- **Rejeitadas:** B porque um run de push skipado fica `completed` (jobs skipados) — "existe run no head" não distingue "run inerte" de "SHA já deployado", e o dedupe mascararia um SHA não publicado (violaria "staging não fica velho"); C porque é automação destrutiva sem decisão de produto (cancelar run de aprovação é ato humano). + +#### D5 — Job de preflight sempre roda (com guard push-only dentro) + +- **Opções:** A) job `preflight` roda nos dois eventos; o step do guard tem `if: github.event_name == 'push'` e o output do job cai em `'true'` por default (`${{ steps.guard.outputs.should_deploy || 'true' }}`); `verify` usa `needs: [preflight]` + `if: needs.preflight.outputs.should_deploy == 'true'`. B) job `preflight` com `if: github.event_name == 'push'` e consumidores com `always()`. +- **Recomendação:** **A** — o `success()` implícito que o GitHub injeta em jobs com `needs` faria o dispatch pular o `verify` se o preflight fosse skipado (footgun conhecido); com A, dispatch e push compartilham um fluxo único e legível. +- **Rejeitadas:** B por depender de `always()` em cada consumidor para não quebrar o dispatch — frágil e fácil de regredir sem teste. + +#### D6 — Semântica de falha da API + +- **Opções:** A) preflight **fail-open** (não conseguiu listar → prossegue, com warning e output `true`) e requeue **fail-red** (não conseguiu comparar/dispatchar → exit 1); B) ambos fail-closed/skip; C) ambos fail-open. +- **Recomendação:** **A** — o preflight é dedupe, não gate de segurança: um blip de API não pode desligar o deploy automático em silêncio (é a dor que o OPS104 resolve), e o pior caso é um `verify` redundante. O requeue é a rede anti-estagnação: falhou, o job fica vermelho (política "nunca mentir", OPS61) e o operador re-dispatcha. +- **Rejeitadas:** B porque reintroduz staleness silenciosa; C porque requeue silencioso deixa `main` velho sem sinal. + +#### D7 — Requeue roda em qualquer evento (push e dispatch) + +- **Recomendação:** o job de requeue roda sempre que `deploy-staging` termina verde (push do trigger 1 **ou** dispatch manual, ref `main`). O pedido é "sempre que o staging terminar e houver head novo". +- **Rejeitadas:** restringir a `github.event_name == 'push'` porque um dispatch de SHA antigo (rollback/incidente) ficaria sem convergência; a consequência (um dispatch de rollback gera também um run novo no head) é inofensiva — produção continua atrás de reviewer e o run novo só propõe o head. + +### Componentes / mudanças + +- **`.github/workflows/deploy.yml`** + - `name: Deploy` (realinha com o novo gatilho; hoje `Deploy (manual)`) e header reescrito (o comentário atual diz "workflow_dispatch only"). + - `on: push: branches: [main]` **somado** a `workflow_dispatch:` (mantido). + - Novo job `preflight` (hosted, `timeout-minutes: 5`, `permissions: { contents: read, actions: read }`, `GITHUB_TOKEN: ${{ github.token }}`, checkout), com `outputs.should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }}` e o step do guard: `if: github.event_name == 'push'` → `node scripts/deploy-preflight.mjs`. + - `verify`: ganha `needs: [preflight]` + `if: needs.preflight.outputs.should_deploy == 'true'` (resto intocado). + - Novo job `requeue` (hosted, `timeout-minutes: 5`, `needs: [deploy-staging]`, `if: needs.deploy-staging.result == 'success'`, `permissions: { contents: read, actions: write }`, `GITHUB_TOKEN: ${{ github.token }}`, checkout) → `node scripts/deploy-requeue.mjs`; **fora** do grupo `deploy-homeserver` e **não** precisa de `deploy-production` (um job aguardando approval não pode bloquear o requeue — decidido no gate). + - Nada muda em `verify` (passos), `deploy-staging`/`deploy-production` (needs `[verify]`/`[deploy-staging]`, environments, concurrency) nem no `scripts/deploy-homeserver.sh`. +- **`scripts/lib/github-api.mjs`** (estender o módulo dono): `listWorkflowRuns(workflowFile, { status, branch, limit = 100 })` (normaliza `{ id, status, headSha, headBranch, event, createdAt, htmlUrl }`) e `getBranchHead(branch = 'main')` → `{ ref, sha }` (REST `git/ref/heads/{branch}`). Reusa `request` (retry OPS67). Sem helper de cancelamento/listagem paginada — escopo estrito. +- **`scripts/lib/deploy-trigger.mjs`** (novo, puro, espelhando `github-pr-flow.mjs`): `ACTIVE_RUN_STATUSES = {queued, in_progress}`; `decidePreflight({ eventName, currentRunId, runs })` → `{ shouldDeploy, reason, activeRunIds }` (dispatch → true; exclui `currentRunId`; só queued/in_progress de outros contam; `waiting`/`requested`/`completed` não); `decideRequeue({ runSha, headSha })` → `{ dispatch, reason }` (SHA ausente → erro no CLI; igual → não; diferente → sim). +- **`scripts/deploy-preflight.mjs`** (novo CLI): lê `GITHUB_EVENT_NAME`, `GITHUB_RUN_ID`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`; dois `listWorkflowRuns('deploy.yml', { status, branch: 'main' })`; chama `decidePreflight`; escreve `should_deploy=` em `$GITHUB_OUTPUT`; em erro de API → warning + `true` (D6). +- **`scripts/deploy-requeue.mjs`** (novo CLI): lê `GITHUB_SHA` e `GITHUB_TOKEN`; `getBranchHead('main')`; `decideRequeue`; se `dispatch` → `api.workflowDispatch('deploy.yml', { ref: 'main' })`; erro → exit 1 (D6). +- **`tests/unit/deployTrigger.unit.spec.ts`** (novo): cobre D1/D4/D7 — dispatch nunca bloqueia; queued/in_progress de outro run pulam; `waiting` e `completed` não; o run atual é excluído; `decideRequeue` compara SHAs. +- **`tests/unit/githubApi.unit.spec.ts`**: casos para `listWorkflowRuns` (URL/params/normalização) e `getBranchHead` (URL/normalização/404→null). +- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: reescrever o pin l.111-122 ("manual-only") para "push em `main` + guard de run ativo + `workflow_dispatch` mantido"; manter l.124-150 (chain OPS103) e o pin do `ci-classify-production`. +- **`tests/unit/deployScript.unit.spec.ts`**: **não** muda (invariante de congelamento do script). +- **Migration:** não (sem schema). +- **Access / Consent:** N/A (sem PII, sem collection). +- **UI:** Impeccable A / N/A — sem superfície de UI; nenhum shell a reusar. + +### Dados → forma (se aplicável) + +N/A — sem superfície de dados para usuário. A "evidência" é operacional: lista de runs, logs dos jobs `preflight`/`requeue` (`should_deploy=…`, `main avançou → dispatch`) e o approval pendente do environment `production`. + +## Fases verificáveis + +1. **Tracer lógico (unit-first, sem YAML)** — `scripts/lib/deploy-trigger.mjs` + `listWorkflowRuns`/`getBranchHead` em `github-api.mjs` + `tests/unit/deployTrigger.unit.spec.ts` + casos novos em `githubApi.unit.spec.ts`. Gate: `pnpm test:unit` verde. _~2–3h._ +2. **Trigger 1 (push + preflight)** — `scripts/deploy-preflight.mjs`; YAML: `on.push.branches[main]`, job `preflight` (com `|| 'true'` no output e guard push-only), `verify` com `needs`/`if`; reescrever o pin do `ciSkipInvariants` (com a mesma filtragem de comentários do teste do chain, para prosa do header nunca satisfazer o pin). Gate: `pnpm test:unit` verde. _~2h._ +3. **Trigger 2 (requeue)** — `scripts/deploy-requeue.mjs` + job `requeue` (needs staging, `actions: write`, fora do grupo). Gate: `pnpm test:unit` verde. _~1–2h._ +4. **Docs + changelog** — checklist da seção "Docs a realinhar" + `docs/changelog/2026-09-14-ops104.md` (uma entrada, additions-only). _~1,5h._ +5. **Gates e entrega** — `pnpm gate:fast` (lint+typecheck+unit), `pnpm test:unit`; `pnpm push`; PR ready (auto-merge); **validação pós-merge** (só existe no caminho `push`, pós-merge): (a) o merge deste PR dogfooda o trigger 1 — conferir run `Deploy` nasceu sozinho, `preflight` logou `should_deploy=true`, `verify` verde, staging publicado, produção aguardando; (b) mergear um follow-up trivial durante o `verify`/staging → conferir que o segundo run ou skipa no preflight ou o `requeue` do run ativo dispara o run novo no head; (c) confirmar que produção só sai com approve e que `deploy-homeserver.sh`/`deployScript` seguem intocados. _~1h + observação._ + +## Rabbit holes / Não escopo (engenharia) + +- Auto-approve de `production`, auto-retry de `verify` vermelho, cancelamento automático de runs antigos (anti-goals do produto). +- Workflow gêmeo/dispatcher separado; mover `verify` para self-hosted; mexer em `ci-pr.yml`, nos guards de banco, em secrets ou na proteção de branch. +- Lock por ambiente no `deploy-homeserver.sh` ou grupos de concurrency separados staging/produção — o lock/grupo compartilhado é decisão da OPS103 (workspace/compose/registry são um só); tratado como risco R2 com gatilho de revisitação. +- Refinar "ativo" por fase do run (listar jobs do run para saber se está em verify/staging/produção) — complexidade sem aceite; o guard por status do run é o decidido. +- Dedupe/cancelamento do trigger 2 (D4); paginação genérica/helpers de runs extras no `github-api.mjs` (status e branch bastam); `actionlint`/validação de YAML nova no CI. +- Qualquer migration/UI/access/Consent (não há). + +## Riscos e mitigação + +- **R1 — Merge via `GITHUB_TOKEN` não cria run de `push` (anti-recursão).** O auto-merge já arma com `AUTOMERGE_PAT` (fail-closed; OPS71-FLIP) → push cria run. _Mitigação:_ invariante existente no `github-pr-automerge.mjs` + `agent-pr-ready-automerge.yml`; se um merge bot aparecer sem run, o dispatch manual é o escape e o gatilho de revisitação é adicionar um fallback por `pull_request: closed` (só com evidência). +- **R2 — Job `waiting` de approval segura o grupo `deploy-homeserver`** (comportamento do GitHub, discussion #67607): o `deploy-staging` de runs novos fica `pending` enquanto a aprovação de produção do run anterior espera; staging pausa (produção não é tocada; nada publica sem reviewer). _Mitigação:_ documentar no runbook (Gatilho e fluxo + Falhas conhecidas); aprovar/rejeitar rápido. _Gatilho de revisitação:_ se aprovações passarem a demorar sistematicamente e o staging velho doer, avaliar grupos/locks por ambiente — exige rever o lock compartilhado do script (hoje deliberado). +- **R3 — Corrida residual: merge durante `deploy-production` `in_progress`.** O push cria run, o preflight vê o deploy ativo e skipa; o requeue daquele run já rodou (após o staging). Nada nasce até o próximo merge ou dispatch manual. _Mitigação:_ runbook "Falhas conhecidas"; escape manual. _Gatilho de revisitação:_ primeira ocorrência real → adicionar um segundo requeue após `deploy-production` verde ou refinar o guard. +- **R4 — Duplicata trigger 1 × trigger 2** (janela de segundos): run redundante/verify duplicado raro. _Mitigação:_ aceito por D4; script idempotente (`already deployed` verde) e deploy serializado pelo grupo. +- **R5 — YAML do `deploy.yml` inválido derruba os dois caminhos** (auto e dispatch usam o arquivo da default branch). _Mitigação:_ mudança pequena e revisada; o merge deste PR é o dogfood (se nenhum run nascer, o próximo merge corrige); manter o dispatch manual documentado. +- **R6 — Custo hosted extra** (~50 min de `verify` por merge/janela). _Mitigação:_ preflight pula quando há deploy ativo; requeue só dispara se o head mudou; cortes de escopo mantêm o `verify` intocado. +- **R7 — `actions: write` do token nativo** no dispatch: o repo já tem jobs com `issues: write`/`contents: write` funcionando (elevação permitida). _Mitigação:_ conferir no primeiro run; se 403, ajustar Workflow permissions do repo (passo humano, sem secret). +- **R8 — Backlog de `queue: max` (até 100):** improvável; o preflight + convergência limitam runs simultâneos. Sem ação; observar. +- **R9 — Docs vivos espalhados** (13+ arquivos, ver seção abaixo): drift vira mentira operacional. _Mitigação:_ checklist obrigatória na fase 4; changelog registra a supersessão do anti-goal da OPS102. +- **R10 — Segurança:** `push` em `main` não vem de fork; jobs novos são hosted e não tocam o homeserver; nenhum secret novo; permissões novas mínimas (`actions: read|write`, `contents: read`); fork-PRs continuam sem CI no repo. + +### Docs a realinhar (linhas de 2026-09-14; realinhar pela frase, não pelo número) + +- `docs/ops/teqo-1313-deploy.md` — l.1 (título "dispatch manual"), l.3-8 (intro "action manual / mergir em main não publica nada"), l.12 (Gatilho), l.54-58 (primeiro deploy/verificação ao vivo), l.75-80 (Staging "mesmo dispatch"), l.225-226 (falhas conhecidas: stale-run/re-dispatch fora de ordem + "already deployed"), l.264 (segurança "apenas dispatch manual"), l.289 (C149 "deploy normal"); **adicionar** linhas de falha para R2/R3. +- `docs/AGENT-OPS.md` — l.3 ("deploy de produção é manual"), l.13, l.19 (diagrama do fluxo), l.25 ("Só humano"), l.82 (tabela de workflows: trigger do `deploy.yml`), l.88, l.105, l.111 (parágrafo do deploy). +- `AGENTS.md` — l.15 ("publishing is a manual `workflow_dispatch`"). +- `AGENTS-infra.md` — l.5 ("publishing is a manual `workflow_dispatch`"). +- `README.md` — l.12, l.17, l.100 (deploy manual). +- `.agents/rules/agent-pr-workflow.mdc` — l.41, l.64 ("There is no deploy from PR CI" continua verdade, mas "manual" muda), l.72. +- `scripts/issue-transition-on-merge.mjs` — l.74 (comentário da mensagem "Deploy de produção é manual"). +- Extras: `.agents/rules/engineering-standards.mdc` l.14; `.agents/skills/bug-fix/SKILL.md` l.54, l.109; `docs/TESTING.md` l.13; `.agents/skills/work-issue/execution-pipeline.md` l.43. +- `deploy.yml` — header e `name`. +- Novo: `docs/changelog/2026-09-14-ops104.md`. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: (1) merge sem deploy ativo → run nasce no head (trigger 1 + preflight); (2) merge durante verify/staging → run novo no head novo após o staging (trigger 2); (3) merge com produção aguardando review → run nasce (`waiting` não conta no guard), ambos pendentes, approve humano decide; (4) nada publica sem `verify` full verde; vermelho = humano resolve (sem retry). +- [ ] Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent; sem secret novo; `deploy-homeserver.sh` intocado (`deployScript.unit.spec.ts` verde sem edição); `verify`/`ci-pr.yml`/guards intocados; produção fail-closed no reviewer; `concurrency: deploy-homeserver` intocado e só nos deploys; anti-goal OPS102 (stale-run guard) não reintroduzido. +- [ ] Testes: `tests/unit/deployTrigger.unit.spec.ts` (novo) e casos de `listWorkflowRuns`/`getBranchHead` no `githubApi.unit.spec.ts` verdes; pin do `ciSkipInvariants.unit.spec.ts` reescrito para "push em main + guard + dispatch" (com comments stripped) e chain OPS103 preservado; `deployScript.unit.spec.ts` sem diff. +- [ ] `pnpm gate:fast` e `pnpm test:unit` verdes; PR ready; validação pós-merge (fase 5) executada e registrada no changelog/runbook. + +### Self-score decision-quality + +| Critério | Nota | Justificativa | +| -------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Decisões caras com rejeitadas | 1,0 | D1–D7 com opções e rejeitadas explícitas (api vs gh vs concurrency; REST vs ls-remote/fetch; owners da lógica; dedupe; job always-run; fail-open/red; escopo do requeue) | +| 2. Cabe no appetite | 1,0 | ~1 dia: 4 fases de ~2h + gates/entrega; nenhuma refatoração do fluxo existente | +| 3. Rabbit holes nomeados | 1,0 | auto-approve/auto-retry, twin, locks por ambiente, refinar "ativo" por fase, dedupe, actionlint fora | +| 4. Depth check (reuso) | 1,0 | estende o client `github-api.mjs` e o padrão `github-pr-flow.mjs` de decisão pura; zero twin de módulo; sem dependência nova | +| 5. Outcome preservado | 1,0 | engenharia não altera o aceite: início automático, staging automático, produção humano-aprovada; R2/R3 documentados sem reescrever o outcome | +| **Total** | **5,0/5** | ≥4 exigido | diff --git a/scripts/deploy-preflight.mjs b/scripts/deploy-preflight.mjs new file mode 100644 index 00000000..9d2852b1 --- /dev/null +++ b/scripts/deploy-preflight.mjs @@ -0,0 +1,61 @@ +/** + * Plain-Node CLI (no pnpm) — OPS104 trigger 1 guard. `deploy.yml` starts on + * every `push` to `main`; this preflight skips the run when another deploy + * run is already `queued`/`in_progress` (a run `waiting` on the production + * approval does NOT count — the human gate must not silence a new merge). + * Manual dispatches never reach the guard: the workflow step is push-only. + * + * Fail-open (OPS104 D6): the guard is a dedupe, not a safety gate — an API + * blip must not silently disable the automatic deploy. Worst case is a + * redundant `verify` run. + * + * Writes `should_deploy=` to `$GITHUB_OUTPUT`. + * + * node scripts/deploy-preflight.mjs + */ + +import { appendFileSync } from 'node:fs' + +import { ACTIVE_RUN_STATUSES, decidePreflight } from './lib/deploy-trigger.mjs' +import { createApi } from './lib/github-api.mjs' + +const writeOutput = (name, value) => { + const line = `${name}=${value}` + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${line}\n`) + else console.log(`[deploy-preflight] ${line}`) +} + +const main = async () => { + const eventName = process.env.GITHUB_EVENT_NAME + if (eventName !== 'push') { + console.log('[deploy-preflight] dispatch manual — should_deploy=true') + writeOutput('should_deploy', 'true') + return + } + + try { + const api = createApi({}) + const runs = [] + for (const status of ACTIVE_RUN_STATUSES) { + runs.push(...(await api.listWorkflowRuns('deploy.yml', { status, branch: 'main' }))) + } + const verdict = decidePreflight({ + eventName, + currentRunId: process.env.GITHUB_RUN_ID, + runs, + }) + const active = + verdict.activeRunIds.length > 0 ? ` (ativos: ${verdict.activeRunIds.join(', ')})` : '' + console.log( + `[deploy-preflight] ${verdict.reason} — should_deploy=${verdict.shouldDeploy}${active}`, + ) + writeOutput('should_deploy', String(verdict.shouldDeploy)) + } catch (error) { + console.warn( + `[deploy-preflight] falha ao consultar os runs (${error instanceof Error ? error.message : error}) — fail-open, should_deploy=true`, + ) + writeOutput('should_deploy', 'true') + } +} + +await main() diff --git a/scripts/deploy-requeue.mjs b/scripts/deploy-requeue.mjs new file mode 100644 index 00000000..8440e127 --- /dev/null +++ b/scripts/deploy-requeue.mjs @@ -0,0 +1,41 @@ +/** + * Plain-Node CLI (no pnpm) — OPS104 trigger 2. Runs hosted after a green + * `deploy-staging`: if `main` advanced while the run was in flight, dispatch + * a new `deploy.yml` from the new head. `workflow_dispatch` is the documented + * anti-recursion exception, so the built-in `GITHUB_TOKEN` (`actions: write`) + * suffices — no PAT. + * + * Fail-red (OPS104 D6): a failure here would leave staging stale in silence — + * exit 1 makes it visible (the operator re-dispatches by hand). + * + * node scripts/deploy-requeue.mjs + */ + +import { decideRequeue } from './lib/deploy-trigger.mjs' +import { createApi } from './lib/github-api.mjs' + +const main = async () => { + const runSha = process.env.GITHUB_SHA + const api = createApi({}) + const head = await api.getBranchHead('main') + if (!head?.sha) { + throw new Error('head de main não encontrado — requeue não verificado') + } + const verdict = decideRequeue({ runSha, headSha: head.sha }) + if (verdict.reason === 'sha-ausente') { + throw new Error(`SHA do run ausente (GITHUB_SHA) — requeue não verificado`) + } + if (!verdict.dispatch) { + console.log(`[deploy-requeue] ${verdict.reason} — sem dispatch`) + return + } + await api.workflowDispatch('deploy.yml', { ref: 'main' }) + console.log(`[deploy-requeue] main avançou (${runSha} → ${head.sha}) — deploy.yml re-dispatchado`) +} + +try { + await main() +} catch (error) { + console.error(`[deploy-requeue] falhou: ${error instanceof Error ? error.message : error}`) + process.exit(1) +} diff --git a/scripts/issue-transition-on-merge.mjs b/scripts/issue-transition-on-merge.mjs index 55ee0bdf..cc6a5416 100644 --- a/scripts/issue-transition-on-merge.mjs +++ b/scripts/issue-transition-on-merge.mjs @@ -71,7 +71,7 @@ for (const number of numbers) { await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] }) await api.addComment( number, - `Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`, + `Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. O deploy começa sozinho no merge (staging automático); produção publica só com o approve do reviewer no environment \`production\` — ver docs/AGENT-OPS.md.`, ) // OPS76: o PR e a Issue vivem no GitHub; o close nativo por keyword // (`Closes #N`) só acontece para issues AINDA abertas citadas no body — diff --git a/scripts/lib/deploy-trigger.mjs b/scripts/lib/deploy-trigger.mjs new file mode 100644 index 00000000..0b2f7192 --- /dev/null +++ b/scripts/lib/deploy-trigger.mjs @@ -0,0 +1,55 @@ +/** + * Pure decision logic for the automatic deploy trigger (OPS104), consumed by + * `scripts/deploy-preflight.mjs` (trigger 1) and `scripts/deploy-requeue.mjs` + * (trigger 2). Unit-tested in tests/unit/deployTrigger.unit.spec.ts. + * + * Product decisions locked in the OPS104 gate (2026-09-14): + * - Only `queued`/`in_progress` runs count as an active deploy. A run + * `waiting` on the production environment approval does NOT block: a merge + * in that window starts a new run and both stay pending for the human. + * - A manual `workflow_dispatch` is always the deliberate act — the guard is + * push-only (the workflow skips the step on dispatch). + * - Trigger 2 only dispatches when `main` really advanced; the same SHA + * (idempotent re-dispatch) is a no-op. + */ + +/** Only these run statuses count as an active deploy in the trigger-1 guard. */ +export const ACTIVE_RUN_STATUSES = new Set(['queued', 'in_progress']) + +/** + * Trigger 1 guard: should this push start the deploy run? + * + * @param {{ + * eventName?: string, + * currentRunId?: string | number | null, + * runs?: Array<{ id: string | number, status?: string }>, + * }} [input] + * @returns {{ shouldDeploy: boolean, reason: 'dispatch-manual' | 'deploy-ativo' | 'sem-deploy-ativo', activeRunIds: Array }} + */ +export const decidePreflight = ({ eventName, currentRunId, runs = [] } = {}) => { + if (eventName !== 'push') { + return { shouldDeploy: true, reason: 'dispatch-manual', activeRunIds: [] } + } + const activeRunIds = runs + .filter((run) => ACTIVE_RUN_STATUSES.has(run?.status)) + .filter((run) => String(run.id) !== String(currentRunId)) + .map((run) => run.id) + if (activeRunIds.length > 0) { + return { shouldDeploy: false, reason: 'deploy-ativo', activeRunIds } + } + return { shouldDeploy: true, reason: 'sem-deploy-ativo', activeRunIds: [] } +} + +/** + * Trigger 2 verdict: did `main` advance past the run's SHA? + * A missing SHA yields `sha-ausente` — the CLI treats it as an error + * (fail-red), never as a silent no-op. + * + * @param {{ runSha?: string | null, headSha?: string | null }} [input] + * @returns {{ dispatch: boolean, reason: 'sha-ausente' | 'head-atual' | 'head-avancou' }} + */ +export const decideRequeue = ({ runSha, headSha } = {}) => { + if (!runSha || !headSha) return { dispatch: false, reason: 'sha-ausente' } + if (runSha === headSha) return { dispatch: false, reason: 'head-atual' } + return { dispatch: true, reason: 'head-avancou' } +} diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index e6e29ef6..7aca7a98 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -311,6 +311,38 @@ export const createApi = ({ body: { ref, inputs }, }), + /** + * GET /actions/workflows/{file}/runs — normalized, newest first. GitHub's + * `status` filter accepts a single value, so callers wanting several poll + * once per status (e.g. OPS104's queued + in_progress). + * @param {string} workflowFile + * @param {{ status?: string, branch?: string, limit?: number }} [options] + */ + listWorkflowRuns: async (workflowFile, { status, branch, limit = 100 } = {}) => { + const payload = await request( + `/repos/${owner}/${name}/actions/workflows/${workflowFile}/runs`, + { query: { status, branch, per_page: limit } }, + ) + const runs = Array.isArray(payload?.workflow_runs) ? payload.workflow_runs : [] + return runs.map((run) => ({ + id: run.id, + status: run.status ?? '', + conclusion: run.conclusion ?? null, + event: run.event ?? '', + headSha: run.head_sha ?? '', + headBranch: run.head_branch ?? '', + createdAt: run.created_at, + htmlUrl: run.html_url ?? '', + })) + }, + + /** GET /git/ref/heads/{branch} — `{ ref, sha }`; `null` when the branch is gone (404). */ + getBranchHead: async (branch = 'main') => { + const ref = await request(`/repos/${owner}/${name}/git/ref/heads/${branch}`) + if (!ref) return null + return { ref: ref.ref ?? '', sha: ref.object?.sha ?? '' } + }, + /** GET /contents/{path}?ref= — { content, sha } base64-decoded. */ getFileContents: async (path, ref = 'main') => { const file = await request(`/repos/${owner}/${name}/contents/${path}`, { query: { ref } }) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 3d89070c..2bb733c3 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -28,6 +28,13 @@ import { const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') +/** YAML with comment-only lines stripped — header prose must never satisfy a pin. */ +const readWorkflow = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8') + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .join('\n') + describe('ciSkipInvariants', () => { it('exports shared path predicates used by classifiers and the lint guard', () => { expect(isSrcPath('src/lib/x.ts')).toBe(true) @@ -108,15 +115,40 @@ describe('ciSkipInvariants', () => { } }) - it('deploy is manual-only — never a push/schedule trigger (OPS71)', () => { - const deploy = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8') + it('deploy starts on push to main with an active-run guard, dispatch kept (OPS104)', () => { + // OPS104 supersedes the OPS71 manual-only pin: a push to main starts the + // deploy run in the same workflow, and the preflight guard skips it when + // another run is queued/in_progress (a `waiting` production approval does + // NOT count — the script decision is pinned in deployTrigger.unit.spec.ts). + // The manual dispatch stays as the escape. Comments are stripped first: + // the header prose also mentions these keys and must never satisfy the pin. + const deploy = readWorkflow('.github/workflows/deploy.yml') expect(deploy).toContain('workflow_dispatch:') - expect(deploy).not.toMatch(/^\s*push:/m) + expect(deploy).toContain(' push:') + expect(deploy).toContain(' branches: [main]') expect(deploy).not.toMatch(/^\s*schedule:/m) + // Trigger 1: push-only guard; the output default keeps a dispatch flowing. + expect(deploy).toContain(' preflight:') + expect(deploy).toContain('node scripts/deploy-preflight.mjs') + expect(deploy).toContain("if: github.event_name == 'push'") + expect(deploy).toContain("steps.guard.outputs.should_deploy || 'true'") + expect(deploy).toContain("needs.preflight.outputs.should_deploy == 'true'") + // Trigger 2: hosted requeue, token-native dispatch. + expect(deploy).toContain(' requeue:') + expect(deploy).toContain('node scripts/deploy-requeue.mjs') + expect(deploy).toContain('actions: write') + // Anti-goal: the requeue stays hosted and OUTSIDE the deploy concurrency + // group — a run waiting on the production approval must not block it. + const requeueBlock = deploy.slice( + deploy.indexOf(' requeue:'), + deploy.indexOf(' deploy-production:'), + ) + expect(requeueBlock).toContain('runs-on: ubuntu-latest') + expect(requeueBlock).not.toContain('concurrency:') // The OPS65 production-change gate died with the Forgejo ci.yml: the - // manual dispatch always runs the full suite (verify) before deploy, so - // no PR workflow may reference the removed classifier. + // full suite (verify) always runs before deploy, so no PR workflow may + // reference the removed classifier. const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8') expect(ciPr).not.toContain('ci-classify-production.mjs') }) @@ -127,11 +159,7 @@ describe('ciSkipInvariants', () => { // not in this YAML); the chain is needs-based so a red staging fail-closes // production. Comments are stripped first: the header prose also mentions // these keys, and it must never satisfy the pin. - const raw = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8') - const deploy = raw - .split('\n') - .filter((line) => !line.trimStart().startsWith('#')) - .join('\n') + const deploy = readWorkflow('.github/workflows/deploy.yml') expect(deploy).toContain(' deploy-staging:') expect(deploy).toContain(' deploy-production:') expect(deploy).toContain(' environment: staging') diff --git a/tests/unit/deployTrigger.unit.spec.ts b/tests/unit/deployTrigger.unit.spec.ts new file mode 100644 index 00000000..8f4a3bc4 --- /dev/null +++ b/tests/unit/deployTrigger.unit.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { + ACTIVE_RUN_STATUSES, + decidePreflight, + decideRequeue, +} from '../../scripts/lib/deploy-trigger.mjs' + +const run = (id: number, status: string) => ({ id, status }) + +describe('deploy-trigger (OPS104)', () => { + it('keeps only queued/in_progress as active statuses', () => { + expect([...ACTIVE_RUN_STATUSES].sort()).toEqual(['in_progress', 'queued']) + }) + + it('never blocks a manual dispatch', () => { + const verdict = decidePreflight({ + eventName: 'workflow_dispatch', + currentRunId: 1, + runs: [run(2, 'in_progress')], + }) + expect(verdict).toEqual({ shouldDeploy: true, reason: 'dispatch-manual', activeRunIds: [] }) + }) + + it('allows a push when no other run is active', () => { + const verdict = decidePreflight({ + eventName: 'push', + currentRunId: 10, + runs: [run(10, 'in_progress'), run(9, 'completed')], + }) + expect(verdict.shouldDeploy).toBe(true) + expect(verdict.reason).toBe('sem-deploy-ativo') + }) + + it('skips the push when another run is queued or in_progress', () => { + const verdict = decidePreflight({ + eventName: 'push', + currentRunId: 10, + runs: [run(11, 'queued'), run(12, 'in_progress')], + }) + expect(verdict).toEqual({ + shouldDeploy: false, + reason: 'deploy-ativo', + activeRunIds: [11, 12], + }) + }) + + it('excludes the current run from the active count', () => { + const verdict = decidePreflight({ + eventName: 'push', + currentRunId: '10', + runs: [run(10, 'in_progress')], + }) + expect(verdict.shouldDeploy).toBe(true) + }) + + it('does not count waiting (production approval) or completed runs as active', () => { + const verdict = decidePreflight({ + eventName: 'push', + currentRunId: 10, + runs: [run(7, 'waiting'), run(8, 'completed'), run(9, 'requested')], + }) + expect(verdict.shouldDeploy).toBe(true) + }) + + it('decideRequeue: same head is a no-op', () => { + expect(decideRequeue({ runSha: 'abc', headSha: 'abc' })).toEqual({ + dispatch: false, + reason: 'head-atual', + }) + }) + + it('decideRequeue: a newer head dispatches', () => { + expect(decideRequeue({ runSha: 'abc', headSha: 'def' })).toEqual({ + dispatch: true, + reason: 'head-avancou', + }) + }) + + it('decideRequeue: a missing SHA is a no-op the CLI treats as an error', () => { + expect(decideRequeue({ runSha: undefined, headSha: 'def' })).toEqual({ + dispatch: false, + reason: 'sha-ausente', + }) + expect(decideRequeue({})).toEqual({ dispatch: false, reason: 'sha-ausente' }) + }) +}) diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 4b047a2a..a178649c 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -123,6 +123,74 @@ describe('github-api (issue tracker layer)', () => { }) }) + it('listWorkflowRuns queries the workflow runs endpoint and normalizes the payload', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok({ + workflow_runs: [ + { + id: 55, + status: 'in_progress', + conclusion: null, + event: 'push', + head_sha: 'abc', + head_branch: 'main', + created_at: '2026-09-14T10:00:00Z', + html_url: 'https://github.com/fsolla/teqo/actions/runs/55', + }, + ], + }) + }, + }) + const runs = await api.listWorkflowRuns('deploy.yml', { status: 'in_progress', branch: 'main' }) + expect(runs).toEqual([ + { + id: 55, + status: 'in_progress', + conclusion: null, + event: 'push', + headSha: 'abc', + headBranch: 'main', + createdAt: '2026-09-14T10:00:00Z', + htmlUrl: 'https://github.com/fsolla/teqo/actions/runs/55', + }, + ]) + expect(calls[0].url).toContain( + '/actions/workflows/deploy.yml/runs?status=in_progress&branch=main&per_page=100', + ) + }) + + it('listWorkflowRuns tolerates a missing workflow_runs array', async () => { + const api = createApi({ token: 'tok', fetchImpl: async () => ok({}) }) + await expect(api.listWorkflowRuns('deploy.yml')).resolves.toEqual([]) + }) + + it('getBranchHead reads and normalizes the branch ref', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok({ ref: 'refs/heads/main', object: { sha: 'deadbeef' } }) + }, + }) + const head = await api.getBranchHead('main') + expect(head).toEqual({ ref: 'refs/heads/main', sha: 'deadbeef' }) + expect(calls[0].url).toContain('/git/ref/heads/main') + }) + + it('getBranchHead returns null on a missing branch (404)', async () => { + const api = createApi({ + token: 'tok', + fetchImpl: async () => ok({ message: 'Not Found' }, 404), + retries: 0, + }) + await expect(api.getBranchHead('nope')).resolves.toBeNull() + }) + it('getFileContents base64-decodes content', async () => { const api = createApi({ token: 'tok',