feat(OPS104): inicia o deploy no merge, com staging automático e produção com review

- 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
This commit is contained in:
Francisco Solla 2026-09-14 02:50:58 -03:00
parent f48c81f625
commit 34c3a1f56f
22 changed files with 736 additions and 108 deletions

View file

@ -38,7 +38,7 @@ Closes #<N>
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (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)

View file

@ -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 <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
## Type honesty

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<slot>` / `teqo_wt<slot>_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names.
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **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 -- <path>` ou `--no-deps --project=<família>`; 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 -- <path>` ou `--no-deps --project=<família>`; 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 <symbol>`) before deleting.
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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 <sha>` **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 <sha>` **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 <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), 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 <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), 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

View file

@ -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).

View file

@ -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`).

View file

@ -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 <sha>` — 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 <sha>` —
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-<env>` → 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:<sha>-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:<sha>-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:<sha>`) 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:<sha>`) 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:<sha>` (+ `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"

View file

@ -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<br/>existe run queued/in_progress<br/>(excluindo o próprio)?"}
M[workflow_dispatch] --> V
PF -- "sim" --> N["run nasce e para<br/>(verify/deploys skipados)"]
PF -- "não" --> V["verify full (hosted)"]
V --> S["deploy-staging<br/>(self-hosted, grupo deploy-homeserver)"]
S --> R{"head de main == SHA do run?"}
R -- "não" --> D["requeue (hosted):<br/>dispatch deploy.yml ref main"]
D -. "novo run" .-> V
R -- "sim" --> W["deploy-production<br/>aguarda reviewer"]
W -- "approve" --> PR["deploy-production<br/>(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=<bool>` 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 |

View file

@ -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=<bool>` 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()

View file

@ -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)
}

View file

@ -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 —

View file

@ -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<string | number> }}
*/
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' }
}

View file

@ -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 } })

View file

@ -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')

View file

@ -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' })
})
})

View file

@ -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',