teqo-backup/docs/ops/teqo-1313-deploy.md
2026-09-15 15:46:01 -03:00

57 KiB
Raw Blame History

Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 + OPS106 + OPS107 — 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).

Desde a OPS107 o approval de produção não ocupa a lane compartilhada (concurrency só no deploy-staging): runs novos seguem fazendo verify e staging e ficam aprováveis independentemente — o operador aprova só o run escolhido (tipicamente o último com staging verde) e rejeita os demais.

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 — e o staging do run novo não espera: desde a OPS107 o approval pendente não segura o grupo). 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. O job de produção não tem concurrency desde a OPS107: a avaliação de concurrency acontece antes do gate do environment e um run waiting segurava o grupo deploy-homeserver, travando o staging (e a aprovação) dos runs novos. O grupo vive só no deploy-staging, que não espera approval (só a vez no próprio grupo) e sempre drena; a execução entre runs é serializada pela cadeia needs, pelo runner self-hosted único e pelo flock do host. Operação: aprove só o run escolhido (tipicamente o último com staging verde) e rejeite os demais — runs não aprovados não publicam nada; se dois forem aprovados, o último deploy executado vence.
  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; desde a OPS107 o concurrency do workflow só existe no staging, então o lock ÚNICO é a serialização real entre runs e 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).
  9. verify vermelho (OPS106): o workflow separado auto-unblock.yml (workflow_run no Deploy, só push + main + failure) dispara um agente autônomo de desbloqueio no homeserver — launcher síncrono de ~1 min que cria/atualiza a Issue token auto-unblock e destaca o agente (scripts/auto-unblock-agent.mjs, cap de 4h, flock /tmp/teqo-unblock.lock); o agente provisiona um worktree fix/* com banco local teqo_wt* (guard fail-closed), roda a skill /bug-fix headless e entrega PR Ready (auto-merge nativo). Agente ativo + nova falha = só comentário no token (nunca enfileira); sem PR = token blocked para o humano. O deploy.yml e o verify não mudam, e não há auto-retry do deploy.

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 validar a OPS107, deixe esse run waiting, dispare um segundo (merge/dispatch) e confirme que o deploy-staging dele roda com o grupo livre; aprove só o escolhido e rejeite o outro. 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. FEITO em 2026-09-15 (túnel home remotely-managed, network_mode: host): no ingress (config API, versão 7) foi inserido staging.jorgesolla1313.com.br → http://localhost:1314 antes do catch-all http_status:404; CNAME staging → <tunnel-id>.cfargotunnel.com (proxied) criado na zona jorgesolla1313.com.br (2ae7416abc4ea930db5ce6782504c578) via CLOUDFLARE_API_TOKEN em ~/Code/infra-solla/.env; docker restart cloudflared aplicou. Validação: https://staging.jorgesolla1313.com.br responde 200 (/, /campanha/login, /admin) pela edge com o staging container. 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:

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

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 ficava pending (resolvido) O job waiting de approval segurava o grupo deploy-homeserver (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) Resolvido na OPS107: o concurrency vive só no deploy-staging (que nunca espera approval); o deploy-production não ocupa lane, então runs novos fazem staging e ficam aprováveis. Aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam; multi-approve = o último deploy executado vence (rollback manual se um SHA indesejado publicar)
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)
verify vermelho Regressão real na suíte full (ou flake — #882/#906) O auto-unblock.yml dispara o agente sozinho (OPS106): acompanhe a Issue auto-unblock (token) e o PR fix/*. Sem PR, o token fica blocked — inspecione o log em ~/teqo-unblock/logs/. Correção manual segue possível (re-merge ou workflow_dispatch)
Agente de desbloqueio não disparou (token inexistente) Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente Rodar pnpm unblock:check no homeserver (com GITHUB_TOKEN e AUTOMERGE_PAT exportados) e seguir a seção Auto-unblock (OPS106) abaixo
Token auto-unblock preso (agente morto, sem PR) Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna skip_comment na próxima falha O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch fix/*; para liberar na hora, feche a Issue token com um comentário

Auto-unblock (OPS106)

Uma falha do job verify dispara um agente autônomo de desbloqueio no homeserver (workflow .github/workflows/auto-unblock.yml, runner self-hosted), com o mesmo contexto de pnpm worktree fix: worktree fix/* provisionado pelo dono (scripts/worktree.mjs fix --headless), bancos locais teqo_wt*/ *_test, skill /bug-fix headless (opencode run --command bug-fix), PR Ready com auto-merge nativo. O registro visível é a Issue token auto-unblock, que linka o run vermelho; o token aberto + o flock /tmp/teqo-unblock.lock são o single-flight (falha com agente ativo só comenta — nunca enfileira).

Guardrails: banco alvo tem de ser local e casar teqo_wt*/*_test (senão o agente nem lança; nunca teqo_1313/teqo_staging, nunca ~/stack/*.env); DATABASE_URL/ALLOW_REMOTE_DB/TEQO_ENV são removidos do env do agente; o agente roda com o PAT (AUTOMERGE_PAT) só no processo destacado, via credential helper não-persistente; cap de 4h; produção continua só com aprovação humana e o deploy não tem auto-retry.

Bootstrap (uma vez, manual — fora do repo)

  1. opencode no homeserver: instalado e autenticado no usuário do runner (opencode auth login; o provider do modelo default — deepseek/deepseek-flash via env OPENCODE_WORKTREE_MODEL para override — precisa estar presente).
  2. PAT: secret AUTOMERGE_PAT no repo com os escopos contents: write, pull requests: write e issues: write (o agente faz push/PR e o outcome comenta/fecha a Issue; o built-in token do job não sobrevive ao processo destacado). O armar do auto-merge continua no agent-pr-ready-automerge.yml.
  3. Docker/Postgres dev: o runner user precisa de Docker; o agente sobe o container dev do repo (docker compose -p teqo com o docker-compose.yml da raiz — serviço postgres, init em docker/postgres/init; porta 5432) — não é o Postgres do stack (stack_default), então nenhum banco de prod/staging é alcançável pelo env do agente.
  4. Deps do checkout: o workflow roda pnpm install --frozen-lockfile no checkout do runner antes do launcher — o wrapper destacado carrega scripts/worktree.mjs, que importa dotenv/pg no load, e o actions/checkout limpa node_modules no runner self-hosted. O --check valida node_modules (rode-o de um checkout com deps instaladas).
  5. Dir do agente: $HOME/teqo-unblock/{logs,state,worktrees} (criado pelo próprio launcher; WORKTREES_ROOT=$HOME/teqo-unblock/worktrees).
  6. Label: auto-unblock (o launcher cria idempotentemente; o token nunca recebe ready/in-progress e não entra na fila de claim).
  7. Smoke: com GITHUB_TOKEN (built-in/PAT) e AUTOMERGE_PAT exportados, pnpm unblock:check valida node/git/pnpm/flock/timeout/docker (binário + daemon)/opencode/auth, porta 5432, dirs, deps do repo, label e permissions.push do PAT — fail-closed com a mensagem acionável.

Operação

  • Token auto-unblock aberto = agente ativo (ou órfão dentro do TTL de 3h). Falha nova com token aberto: comenta no token, não dispara.
  • Sem PR ao fim: token recebe blocked e fica aberto (gate humano) — o log está em ~/teqo-unblock/logs/unblock-<run-id>.log.
  • Limpeza: worktrees/databases acumulam um punhado por falha; apague com pnpm worktree kill de dentro do worktree (dropa os bancos do worktree). Gatilho para automatizar retention: ≥3 worktrees/DBs teqo-unblock acumulados (ls ~/teqo-unblock/worktrees).

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 (exceção OPS106: o agente de desbloqueio é um job self-hosted disparado por verify vermelho — launcher fino + agente destacado com guardrails fail-closed; ver seção Auto-unblock). Fork-PRs não rodam CI (same-repo gate + setting do repo).
  • Agente de desbloqueio (OPS106): o auto-unblock.yml roda código na máquina de produção justamente quando o verify falha. Mitigações: gatilho restrito (push em main + job verify falho conferido pela jobs API), launcher fino (só decide/spawna e sai), flock + Issue token (single-flight), agente sem RUNNER_TRACKING_ID com cap de 4h, banco local teqo_wt*/*_test fail-closed, env sem DATABASE_URL/ALLOW_REMOTE_DB/TEQO_ENV, PAT por credential helper não-persistente, entrega só por PR Ready com auto-merge (nunca push direto em main) e produção ainda com approval humano. Risco residual aceito: o agente tem Docker e o PAT no homeserver — sem sandbox/usuário dedicado nesta fatia (revisitar só com evidência de abuso).
  • 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.

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

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

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:

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

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:

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.

Operação de dados do acervo de falas (C153/C154/C155): troca o officialTextUrl legado (dc_20b.asp/dc_20.asp/montaPdf.asp, que pendura no browser) pelo PDF direto do Diário (https://imagem.camara.leg.br/Imagem/d/pdf/<arquivo>#page=<página>). É script-only (nenhuma migration): resolve uma vez por publicação (<out>/diario/), é idempotente e reporta cobertura (falas/datas atualizadas, falhas e legado restante).

Pré-requisitos: o SHA do branch/commit com o C160 (a passada original rodou de ~/teqo-backfill no SHA 766e4ae9, 2026-09-14); ~/stack/.env + ~/stack/teqo-1313.env; ~/teqo-backfill com pnpm install no SHA. O git fetch do homeserver alcança o GitHub pelo bundle/com o remote disponível (na passada original o clone apontava para repo local e usou-se git fetch ~/c160.bundle <branch>).

ssh homeserver
source ~/.nvm/nvm.sh
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CAMARA_IMPORT_CONFIRM=1                 # guard C160: escrita em produção

# dry-run (read-only; aquece o cache por publicação em <out>/diario):
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
# escrita (idempotente; 2ª passada = 0 atualizações):
pnpm camara:import --repair-links --out /srv/hdd/backups/teqo-camara
# validação (0 legadas restantes):
pnpm camara:import --repair-links --dry-run --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); --dry-run é read-only e não exige a flag. A escrita é confinada a speech.officialTextUrl; VOD, transcrições e segmentos não são tocados.

Comportamento medido da Câmara (2026-09-14): o legado .gov.br responde 302 para .leg.br (hop lento, 25–135s) e o .leg.br responde 302 para montaPdf.asp?narquivo=…&npagina=… (~5s); o PDF direto responde 200/206 application/pdf em <1s. O resolver reescreve .gov.br→.leg.br no lookup, segue o redirect e parseia o montaPdf; a resolução é cacheada uma vez por publicação (diario/<data>-<coleção>[-s<suplemento>].json) e cada PDF é provado com GET Range antes de gravar. Falha de resolução deixa a fala intocada, o run sai 1 e a próxima passada é idempotente.

Resultado registrado (2026-09-14): dry-run achou 965 falas legadas em 504 publicações (33 em selCodColecaoCsv=J e 1 com o parâmetro vazio, normalizadas para D; a Câmara devolve "Documento não encontrado" nos códigos obsoletos e a mesma data/página resolve em D) + 32 falas sem link (fallback YouTube/oculto); resolução cacheada das 504 publicações (retries absorvem fetch failed transiente); o write atualizou 965 falas em 58s (remainingLegacy: 0, 0 falhas) e a validação read-only deu 965 diretas/0 legadas com o --coverage inalterado (997 discursos). Relatórios em /srv/hdd/backups/teqo-camara/reports/repair-links-*.json e changelog docs/changelog/2026-09-14-c160.md.

Rollback

Não há migração; o reparo só reescreve speech.officialTextUrl e o valor anterior de cada fala está em updates[].previousUrl no JSON do run. O import (pnpm camara:import --date <dia>) reconstrói o link a partir da API (a fonte é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.

C163 — relatório de cidade pré-viagem (leitura read-only)

O gerador de PDF por cidade (/work-issue C163) extrai um snapshot da base de produção sem escrever nada: a sessão Postgres é aberta com default_transaction_read_only=on (options da connection string, aplicada pelo próprio scripts/extract-city-report-snapshot.mjs) e o render acontece fora do homeserver. Nenhum artefato é persistido na base; o snapshot e o PDF/MD são gitignored (data/relatorios-cidade/, docs/research/relatorios-cidade/).

Fluxo completo e contrato dos JSONs: skill .agents/skills/relatorio-cidade. Receita do homeserver (checkout de scratch, nunca o ~/teqo-deploy do deploy em andamento):

ssh homeserver
source ~/.nvm/nvm.sh                     # Node 24 (engines do repo)
test -d ~/teqo-report || git clone https://github.com/fsolla/teqo.git ~/teqo-report
cd ~/teqo-report
git fetch origin main && git checkout main && git pull --ff-only origin main
# tsx vem do install completo (o deploy usa --prod e não traz devDeps):
[ -d node_modules/tsx ] || pnpm install --prod=false
set -a; source ~/stack/teqo-1313.env; set +a
# 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 CITY_REPORT_CONFIRM=1              # guard de intenção explícita
NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
  node scripts/extract-city-report-snapshot.mjs \
    --municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json

Depois, na workstation: scp homeserver:~/teqo-report/data/relatorios-cidade/<slug>.snapshot.json data/relatorios-cidade/ e node scripts/build-city-report.mjs --snapshot=... --research=.... O snapshot carrega readAt + SHA do código — o PDF é datado por construção.

Guardas: sem CITY_REPORT_CONFIRM=1 o extrator recusa; a URL com options read-only é aplicada antes do Payload subir (qualquer write estoura no servidor); PORTAL_TRANSPARENCIA_API_KEY é opcional (sem chave, emendas viram lacuna explícita, nunca zero). Nunca aponte o extrator para o banco local esperando dados de produção — o caminho é o proxy do homeserver.

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"