teqo-backup/docs/ops/teqo-1313-deploy.md
Francisco Solla f58a712ad9 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
2026-09-14 03:07:59 -03:00

534 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 — início automático, produção com review)
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. **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 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`).
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`.
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
verify; o workspace é clonado/atualizado de `TEQO_REPO_URL` — default
`https://github.com/fsolla/teqo.git`, público) →
guard "already deployed" (revision do container do ambiente) → **build do
migrator** (o estágio migrator não roda `next build` — builda mesmo contra o
schema antigo; BuildKit, secrets do env file do ambiente) → push do migrator
em `localhost:5000` (tag **qualificada de registry** — INF13: a única ref
que o compose referencia) → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes; sed ancorado ao serviço do
ambiente — a linha do outro ambiente não é tocada) → **migrations**
(`docker compose --profile maintenance run --rm teqo-<env>-migrate
</dev/null`, já com a imagem do SHA novo) → **build do runner** (contra o
banco JÁ migrado — o `next build` da geração estática lê o schema novo,
OPS66; `--network host` com proxy socat por ambiente na `stack_default` para
alcançar o `postgres`; o proxy é criado idempotentemente pelo script) →
push do runner (tag qualificada de registry) → `docker compose up -d
teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do
ambiente).
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):** 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
| Máquina | Papel |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente
(`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Staging (OPS103)
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes
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`).
| Item | Staging | Produção |
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
| Container | `teqo-staging` (127.0.0.1:1314) | `teqo-1313` (127.0.0.1:1313) |
| Migrator | `teqo-staging-migrate` | `teqo-1313-migrate` |
| DB | `teqo_staging` | `teqo_1313` |
| Env file | `~/stack/teqo-staging.env` | `~/stack/teqo-1313.env` |
| Imagens | `localhost:5000/teqo-staging(-migrator):$SHA` | `localhost:5000/teqo-1313(-migrator):$SHA` |
| Bucket | `teqo-media-staging` | `teqo-media` |
| URL | `https://staging.jorgesolla1313.com.br` (noindex) | `https://jorgesolla1313.com.br` |
| Build proxy | `teqo-staging-build-proxy` (127.0.0.1:5434) | `teqo-1313-build-proxy` (127.0.0.1:5433) |
| Lock | `/tmp/teqo-deploy.lock` (compartilhado — compose/workspace/registry são um só) | idem |
### Bootstrap do homeserver (uma vez, manual — fora do repo)
1. **DB:** criar `teqo_staging` no Postgres do stack
(`docker compose exec postgres createdb -U teqo teqo_staging`).
2. **Env file:** `~/stack/teqo-staging.env` (chmod 600) com `DATABASE_URL`
apontando para `teqo_staging`, `PAYLOAD_SECRET` próprio,
`NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br`,
`REVALIDATE_SECRET` próprio e `S3_*` do bucket `teqo-media-staging`.
3. **Compose:** adicionar `teqo-staging` e `teqo-staging-migrate` ao
`~/stack/docker-compose.yml`, espelhando os serviços de produção
(`pull_policy: never`, label `org.opencontainers.image.revision` **por
serviço**, healthcheck, `extra_hosts: host-gateway`) e a porta
`127.0.0.1:1314`.
4. **Bucket:** criar `teqo-media-staging` no Garage com key própria — nunca
reutilizar a key/bucket de produção.
5. **Tunnel/DNS:** ingress `staging.jorgesolla1313.com.br` →
`http://localhost:1314` no cloudflared + DNS no Cloudflare. Opcional
(defesa em profundidade): header `X-Robots-Tag: noindex` no ingress — o
noindex versionado (robots.ts + metadata, OPS103) já cobre crawlers.
6. **GitHub Environments:** criar `staging` (sem reviewer) e `production`
(required reviewer `fsolla`) em Settings → Environments — **antes do
primeiro dispatch** (environment `production` sem reviewer é fail-open).
7. **Migrate + seed sintético (antes do primeiro dispatch):** no homeserver,
com o workspace em `~/teqo-deploy`, criar o proxy de staging manualmente
(o deploy também cria, idempotente), migrar e semear. O guard local passa
porque o alvo é `127.0.0.1` — **sem** `ALLOW_REMOTE_DB`:
```bash
ssh homeserver
cd ~/teqo-deploy && pnpm install
docker inspect teqo-staging-build-proxy >/dev/null 2>&1 || docker run -d \
--name teqo-staging-build-proxy --network stack_default \
--restart unless-stopped -p 127.0.0.1:5434:5434 \
alpine/socat TCP-LISTEN:5434,fork TCP:postgres:5432
set -a; source ~/stack/teqo-staging.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
pnpm migrate
pnpm db:seed:minimal
```
Nunca copiar dados de produção (PII/LGPD): o seed mínimo é sintético.
### Limites do staging (o que ele NÃO valida)
- e2e automatizado (o full continua no `verify`, que valida o commit);
- carga, caos, multi-região;
- integrações origin-bound sem configuração própria: WebAuthn/passkeys,
Google OAuth e envio de e-mail (Resend). "Staging verde" ≠ "produção
garantida" — é validação de migração/build/rollout/smoke + inspeção humana;
- dados são sintéticos; nunca PII real.
## Cutover OPS71 (passos manuais, ordem)
1. Remotes locais (config compartilhada pelos worktrees):
`git remote set-url origin git@github.com:fsolla/teqo.git` e
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
main; o tracker (Issues) segue vivo por API.
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
General, defina fork PR workflows como não executar (ou exigir aprovação).
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
sem required check).
5. **Desligar o runner do Forgejo** na workstation (ex.:
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
validado; para o schedule do `ci.yml` antigo.
6. Instalar o runner self-hosted do GitHub no homeserver (labels
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
registrar em infra-solla: `STATE.md`).
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
abrir PRs no Forgejo a partir do main congelado — os workflows de
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
## Rollback (do deploy)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`
(um por deploy; como o staging roda antes no mesmo SHA, o backup de produção é
o compose já com a linha do staging — o restore devolve o arquivo inteiro, o
que é o correto).
```bash
ssh homeserver
cd ~/stack
# 1. apontar o compose de volta para o SHA anterior (o que estava rodando):
# o backup do deploy que falhou é o compose com os tags antigos
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza;
# o compose referencia a tag qualificada — INF13 — e o pull basta):
docker pull localhost:5000/teqo-1313:<sha-anterior>
# Cuidado (janela de transição pós-INF13): backups gerados ANTES do fix
# usam tags bare (`image: teqo-1313:<sha>`); se o backup escolhido tiver
# tag sem prefixo, recrie o alias antes de subir:
# docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
# 3. subir:
docker compose up -d teqo-1313
```
Para **staging**, o mesmo procedimento com as identidades do ambiente
(`teqo-staging`, `localhost:5000/teqo-staging:<sha-anterior>`,
`docker compose up -d teqo-staging`) — o rollback de um ambiente não toca o
container do outro (só restaura as linhas de imagem do arquivo compartilhado).
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
roda após migrator build+push+swap ok; rollout só após runner build ok). Desde
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
novo): um build do runner que falhe depois do migrate deixa o banco à frente
do código — migrations são append-only e revisadas antes do deploy (checklist);
a correção se re-mergeia e o próximo deploy completo publica.
## Falhas conhecidas
| 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 | 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` |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) |
## Backup passivo no Forgejo (OPS76-FOLLOWUP, 2026-08-21)
O GitHub é a única fonte (main, branches, PRs, CI, tracker). O Forgejo mantém
um **backup passivo** via **pull mirror nativo**: o repo `teqo-backup` no
Forgejo (`https://git.solla.dev/fsolla/teqo-backup`) espelha o GitHub
automaticamente — **não serve push, não recebe PRs, é somente leitura**.
- **Repo:** `fsolla/teqo-backup` no Forgejo (`mirror: true`), criado via
`POST /api/v1/repos/migrate` com `clone_addr=https://github.com/fsolla/teqo.git`,
`service=github`, `mirror=true`, intervalo **8h** (`mirror_interval`).
- **Automação:** o próprio Forgejo sincroniza no intervalo (sem workflow).
Force sync manual: `POST /api/v1/repos/fsolla/teqo-backup/mirror-sync`
(o "Synchronize now" da UI).
- **Cobertura:** espelha todas as branches (54/54 verificadas) e tags; o
histórico do mirror reflete o do GitHub (que é a convergência OPS75 +
OPS76). Não espelha issues/PRs/labels (GitHub é o tracker).
- **Validação:** `git ls-remote` do `teqo-backup` deve retornar o mesmo SHA
de `refs/heads/main` do GitHub; conferir em `mirror_updated` o último sync.
- **O repo `fsolla/teqo` original do Forgejo** fica **congelado** no ponto da
convergência (OPS75) — arquivo histórico, sem mais uso.
## Segurança (decisão deliberada)
- 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` (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 —
nunca compartilha credencial, bucket ou dados com produção.
## C149 — Google OAuth da agenda (envs novas + GCP)
O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem
as envs, o diálogo cai no runbook antigo da service account — que segue como
fallback e não foi removida por esta entrega.
1. No GCP (mesmo projeto da service account), crie um OAuth client **Web
application** com os redirect URIs:
- `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback`
- `http://localhost:<porta>/campanha/agenda/google-oauth/callback` (dev)
2. Publique o app em produção — **unverified** é aceito (uso interno). Em
"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 (`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
passo).
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e
`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada
desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue
viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81).
**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra
no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de
campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do
OPS51. O único residual real entre as plataformas era a `signature` pública
1486 (e seu contato/subscription), cujo contato originário **não existia** no
target (o id 2221 do target é OUTRA pessoa, Jorge Solla).
### Reconciliador (read-only, repetível)
Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo
semântico das join-tables de relações contra a fonte Neon.
```bash
ssh homeserver
cd ~/teqo-deploy
git fetch origin main && git checkout main && git pull --ff-only origin main
pnpm install
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o
# proxy socat do build (127.0.0.1:5433):
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile
```
Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` —
edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova
divergência. Nunca imprime PII.
### Migração do residual (one-off, com guard de confirmação)
Copia a `signature` 1486 + seu contato + subscription (a transação que o
`submitPetitionSignature` faz junto), reusando petition/consent já presentes e
criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa).
```bash
# dry-run (valida referentes e imprime o plano sem escrever):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run
# aplica (ESCREVE em prod — exige a flag de intenção explícita):
OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply
```
Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de
Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2),
subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id =
último+1). Sequências conferidas após: contact 2225 / signature 1486 /
subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e
reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200.
### Rollback
A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se
precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta:
`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492;
DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;`
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
`setval('subscription_id_seq',1491)`.
## OPS84 — Verificação de valores (não só entidades) da migração
Extensão do reconciliador OPS79: compara TODAS as colunas de conteúdo (não só
ids/contagens) das 13 tabelas da vertical campanha entre a fonte congelada
(Neon) e a base nova (`teqo_1313`). Foco confirmado em `vote_pledge` (votos
declarados + 3 cenários de estimativa).
**Resultado registrado em 2026-08-24:** 13/13 tabelas PASS — **0 divergências
de conteúdo**. A migração de valores está 100% íntegra. Neon e target com
dados idênticos em todas as 13 tabelas da vertical.
### Verificador de valores (read-only, repetível)
```bash
ssh homeserver
cd ~/teqo-deploy && git fetch origin main && git checkout main && git pull --ff-only origin main
pnpm install
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
# Todas as 13 tabelas (foco em vote_pledge):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values
# Tabela isolada (debug/iteração):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --table vote_pledge
# Com relatório markdown (anexável ao issue):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --report /tmp/ops84-report.md
```
**Esperado:** `13/13 PASS` com 0 content diffs. Exit `1` se houver divergência
de conteúdo. Colunas classified em 3 buckets:
- **content** — falha o run (aceite: "0 divergência" ou "N listadas")
- **derived** — hook-derivadas (`declared_at/by`, `estimated_at/by`,
`created_at`, `updated_at`); informativas, não falham
- **sensitive** — PII; valores nunca impressos, só contados
Colunas jsonb comparadas via `col::text` (preserva `1` vs `1.0` como
divergência; SQL NULL ≠ JSON null). Zero normalização (trim/round/coalesce).
### Fallback: Neon inacessível (OPS81 já executado)
Se o Neon estiver inacessível (OPS81 desligou a infra), usar o dump do OPS51
como base de comparação:
```bash
# Restaurar dump em scratch DB local:
createdb teqo_1313_compare_src
pg_restore -d teqo_1313_compare_src \
/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump
# Apontar NEON_DATABASE_URL para o scratch:
NEON_DATABASE_URL="postgresql://teqo:teqo@localhost:5432/teqo_1313_compare_src" \
DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --report /tmp/ops84-report-dump.md
```
**Nota:** dump é pré-freeze (2026-08-17) — deltas legítimos entre 08-17 e
08-23 (quando o Neon foi congelado) aparecem como divergências derivadas. O
relatório declara qual base foi usada.
### Rollback
Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
## C155 — backfill do acervo de falas em produção
Operação de dados do catálogo de falas (`speech`/`speechSegment`, C153/C154):
processa as 54ª–57ª legislaturas (~1.011 discursos) contra o `teqo_1313` e
imprime um relatório por legislatura + a cobertura do banco. O import é
idempotente por `sourceKey` (reexecutar atualiza metadados e pula VOD/ASR do
que já tem segmentos), então o run é resumível — falha de rede em uma
legislatura não derruba as outras.
**Pré-requisitos:** o deploy que aplicou as migrations do catálogo
(`20260913_001112_add_speech_catalog`, `20260913_001200_add_speech_segment_trgm_index`,
`20260913_145820_add_speech_search_text`, `20260913_150000_backfill_speech_search_text_trgm_index`)
já está em produção desde o deploy de `424ee311` (2026-09-13); `DEEPINFRA_API_KEY`
no `~/stack/.env` do homeserver; `~/teqo-backfill` no SHA desejado com
`pnpm install`; espaço em `/srv/hdd/backups/teqo-camara` para o cache de MP4
(o cache do C153 foi descartado com o worktree; o backfill baixa de novo).
```bash
ssh homeserver
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a # DEEPINFRA_API_KEY
set -a; source ~/stack/teqo-1313.env; set +a # DATABASE_URL + NODE_ENV=production
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CAMARA_IMPORT_CONFIRM=1 # guard C155: escrita em produção
# smoke (4 discursos, ~2 min; repetir para provar idempotência: 0 criados/0 ASR):
pnpm camara:import --all --limit 1 --out /srv/hdd/backups/teqo-camara
# run completo (tmux; ~10–15h de parede, resumível):
tmux new -d -s c155 "pnpm camara:import --all --out /srv/hdd/backups/teqo-camara 2>&1 | tee -a /srv/hdd/backups/c155-run.log"
# validação pós-run (read-only):
pnpm camara:import --coverage --out /srv/hdd/backups/teqo-camara
pnpm camara:import --verify-links 3 --out /srv/hdd/backups/teqo-camara
```
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
`--coverage` e `--verify-links` são read-only e não exigem a flag. A escrita é
confinada a `speech`/`speechSegment`/rels; nenhum objeto de mídia vai para o
S3 (os MP4 ficam só no cache local). Crédito CC BY 4.0 da Câmara mantido no
admin/relatórios.
**Resultado registrado (2026-09-13/14):** **997 discursos** em produção
(54ª=1, 55ª=348, 56ª=414, 57ª=234) — 947 com trecho, 924 com vídeo, 920 com
segmentos; ASR 925 chamadas / 45,4h / ~US$1,23; LLM ~US$0,11; ~11h de máquina
(9h de runs + ~2h de retry por data). A 55ª veio pelo fallback de listagem
por ano/mês (a API responde 500 em **outubro/2018**; ~14 discursos ficaram
fora — reexecutar `pnpm camara:import --date <dia>` quando a fonte voltar).
O smoke idempotente (`--all --limit 1` duas vezes) provou 0 criados/0 ASR/0 LLM
na segunda passada; `--verify-links 3` deu 17/18 ok (1 warning transitório de
CDN). Relatórios completos em `/srv/hdd/backups/teqo-camara/reports/` e no
changelog `docs/changelog/2026-09-13-c155.md`.
### Rollback
O import é aditivo e idempotente; não há migração para desfazer. Para remover
uma legislatura (ex.: reimportar do zero), apagar os discursos e seus
segmentos — as rels (`speech_rels`, `speech_topics`, `speech_scopes`) caem por
cascade:
```sql
DELETE FROM "speech_segment" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
DELETE FROM "speech" WHERE "legislature" = '55';
```
Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "speech";`
(a cobertura `--coverage` volta a zero). Reexecutar `--all` reconstrói.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
- `.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"