diff --git a/.agents/skills/engineering-audit/SKILL.md b/.agents/skills/engineering-audit/SKILL.md index 6e344de2..37aca26f 100644 --- a/.agents/skills/engineering-audit/SKILL.md +++ b/.agents/skills/engineering-audit/SKILL.md @@ -5,7 +5,7 @@ description: Runs a Pass-style engineering audit of the Teqo codebase — code s # Engineering Audit (Pass N) -**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. Se o **agent-pool** estiver ligado, pause-o no início do audit (`gh workflow run agent-pool.yml -f action=pause`) e retome ao final (`-f action=resume`); o tick recusa spawn enquanto `POOL_PAUSED=true`. Em **Cursor Cloud** o `gh` do agente é read-only e o pause por dispatch não está disponível — vale o precheck fail-closed da seção "Modo autônomo (Cursor Cloud)". +**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. O **agent-pool está morto desde OPS65** (workflow removido; scripts/skills dormentes) — nada a pausar. ## Modo autônomo (Cursor Cloud) diff --git a/.agents/skills/model-selection/SKILL.md b/.agents/skills/model-selection/SKILL.md index 058d858a..eb4372c7 100644 --- a/.agents/skills/model-selection/SKILL.md +++ b/.agents/skills/model-selection/SKILL.md @@ -89,4 +89,4 @@ Bipartir (`{id}-plan` Grok High → `{id}-exec` `kimi-k3-low`) só sob pedido ex - **`work-issue`:** **não verifica modelo** — a sessão nasce no modelo fixo do ambiente (máquina do humano: DeepSeek V4 Flash). `model:` da Issue é metadata consultiva (o pool spawna nele; o claim brief o imprime). - **`agent-work-issue`:** pool já fixou o modelo no spawn; sessão mais forte → informa e segue. - **`plan-issue`:** sugere via esta tabela (slug único completo com effort); bipartição só sob blast radius extremo / pedido explícito. -- **`agent-pool` smoke:** Composer default; Grok com effort explícito / Kimi K3 Low em Issues de teste bipartidas. +- ~~`agent-pool` smoke~~ — **dormente (OPS65)**: workflow removido; pool morto. diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b4a306e7..da584e05 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,20 +1,23 @@ # Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the # workflow no longer fires on every push. The `schedule` trigger polls every -# 30 min and a cheap gate (`check`, runs-on host, no pnpm) classifies whether -# anything that would enter the docker build context changed since the SHA -# running on the homeserver (read from `~/stack/docker-compose.yml` over ssh). -# - No production change → suite + deploy skip; the run stays green +# 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether +# anything that would enter the docker build context changed since the +# revision running on the homeserver (the `org.opencontainers.image.revision` +# label of the running container, read over ssh). +# - Deployed == HEAD → suite + deploy skip; the run stays green # (the `checks` rollup already accepts skipped jobs). -# - Production change → full suite + deploy in the same window. +# - Anything else (production change, unknown deployed state, dispatch) → +# full suite; deploy in the same window. # - `workflow_dispatch` always runs the full pipeline (explicit operator # intent); the deploy is idempotent via the "already deployed" guard in # scripts/deploy-homeserver.sh. # -# Gate fail-open: an unreadable deployed SHA or an ssh failure makes `check` -# report production=true, so the suite ALWAYS verifies main. The deploy job -# only runs when the gate succeeded AND production changed AND checks are -# green — never deploy blind. A gate failure self-heals on the next window -# (the diff is re-evaluated against the same deployed SHA). +# Fail-open: an unreadable/unknown deployed revision makes the gate report +# production=true, so the suite ALWAYS verifies main and a quiet day only +# skips when we KNOW deployed == HEAD. A gate that cannot classify at all +# (git/.dockerignore error) exits non-zero and turns the run RED — never a +# silent green blackout. The deploy job only runs when the gate succeeded +# AND production changed AND checks are green — never deploy blind. # # When the suite is green, the `deploy` job publishes teqo-1313 on the # homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver @@ -43,13 +46,14 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: - check: - name: check production change + gate: + name: gate production change # Host runner: needs `ssh homeserver` (same environment as the deploy # job — the key lives on the workstation host) and a full clone to diff - # ..HEAD. Runs every window; the suite jobs below consume - # its `production` output. Deliberately wired ONLY here — main + dispatch - # — never in ci-pr.yml (host job = code execution as `fsolla`). + # ..HEAD. Main + dispatch only: a branch dispatch must not + # run that branch's code on the host — the skipped gate makes the suite + # run fail-open on ubuntu containers instead (test-only secrets). + if: github.ref == 'refs/heads/main' runs-on: host timeout-minutes: 10 outputs: @@ -58,22 +62,34 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Read deployed SHA and classify production change + - uses: actions/setup-node@v5 + with: + node-version: 24 + package-manager-cache: false + - name: Read deployed revision and classify production change id: classify run: | set -euo pipefail - deployed_sha="$(ssh homeserver "grep -oE 'image: teqo-1313:[0-9a-f]+' ~/stack/docker-compose.yml | sed -E 's/.*://' | head -1" 2>/dev/null || true)" + # Truth = the RUNNING container's revision label (the compose file + # can lie after a failed rollback — it is swapped before the + # rollout). Empty/malformed = unknown deployed state → the + # classifier reports production=true (full run, green). + deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)" + if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then + deployed_sha="" + fi echo "deployed_sha: ${deployed_sha:-}" - node scripts/ci-classify-production.mjs --deployed "$deployed_sha" - echo "production=$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha" --value)" >> "$GITHUB_OUTPUT" + out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")" + printf '%s\n' "$out" + echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT" static: runs-on: ubuntu-latest timeout-minutes: 20 - needs: [check] + needs: [gate] # Fail-open: an errored gate never skips the verifier; only a gate that # succeeded AND found no production change skips the suite. - if: needs.check.result != 'success' || needs.check.outputs.production == 'true' + if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 @@ -93,8 +109,8 @@ jobs: int: runs-on: ubuntu-latest timeout-minutes: 25 - needs: [check] - if: needs.check.result != 'success' || needs.check.outputs.production == 'true' + needs: [gate] + if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' env: # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds # a Payload pool (max 10) → overflows the service Postgres' 100 clients @@ -139,8 +155,8 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 25 - needs: [check] - if: needs.check.result != 'success' || needs.check.outputs.production == 'true' + needs: [gate] + if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' services: postgres: image: postgres:17-alpine @@ -174,8 +190,8 @@ jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 40 - needs: [check] - if: needs.check.result != 'success' || needs.check.outputs.production == 'true' + needs: [gate] + if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' # Main always runs the full suite: split it across 2 jobs (OPS34), each # building its own `.next-e2e` (measured 2026-08-10: build 172s + tests # 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast: @@ -272,8 +288,8 @@ jobs: # must have found a production change. The explicit `if` prevents both # "deploy blind on a failed gate" and "deploy with a red suite" (a bare # output condition would run despite failed needs). - needs: [checks, check] - if: needs.checks.result == 'success' && needs.check.outputs.production == 'true' + needs: [checks, gate] + if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true' runs-on: host timeout-minutes: 60 steps: diff --git a/AGENTS.md b/AGENTS.md index 49692ca2..19d9d9b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17; windowed since OPS65, 2026-08-18)**: `ci.yml` no longer fires on every push — it runs in a **fixed 30-minute window** (`schedule */30` + `workflow_dispatch`), where a cheap gate job (`check`, host runner, no pnpm) reads the SHA deployed on the homeserver from `~/stack/docker-compose.yml` over SSH, diffs it against `main` HEAD and classifies "production change" using `.dockerignore` as the source of truth (docs/`.agents`/`.env*` changes skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` count as production — conservative). No production change → suite + deploy skipped, run stays green; production change → full suite + deploy in the same window; dispatch always runs the full pipeline. The `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script is idempotent ("already deployed" → skip), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado em até 30 min. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17; windowed since OPS65, 2026-08-18)**: `ci.yml` no longer fires on every push — it runs in a **fixed 30-minute window** (`schedule */30` + `workflow_dispatch`), where a cheap gate job (`check`, host runner, no pnpm) reads the SHA deployed on the homeserver from `~/stack/docker-compose.yml` over SSH, diffs it against `main` HEAD and classifies "production change" using `.dockerignore` as the source of truth (docs/`.agents`/`.env*` changes skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` count as production — conservative). No production change → suite + deploy skipped, run stays green; production change → full suite + deploy in the same window; dispatch always runs the full pipeline. The `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script is idempotent ("already deployed" → skip), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` com mudança de produção verde == site atualizado em até ~1 h (janela de 30 min + suite + deploy). Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index b0be0201..da719194 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -15,7 +15,7 @@ Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB` ```text claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main -main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge em main == site atualizado em ≤30 min) +main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge com mudança de produção == site atualizado em ~30 min + suite/deploy) ``` **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` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues. @@ -93,7 +93,7 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`. Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente). -Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `check` (host, sem pnpm) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` etc. viram skip; `scripts/`, `tests/`, `.forgejo/` contam como produção — conservador). Sem mudança → suite e deploy skipped (run verde); gate com erro → suite roda (fail-open) e o deploy espera a próxima janela. Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente ("already deployed" → skip verde). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50. +Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50. ## Cursor Cloud @@ -101,7 +101,11 @@ Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`s ## Agent pool -Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` + dispatch. Workers PR `--base main`. Default model `composer-2.5`. **Secret `POOL_GITHUB_TOKEN`** obrigatório se `GITHUB_TOKEN` 403 em `/actions/variables`. Doctor: `pnpm agent:pool -- doctor`. Skill: `agent-pool`. +**Dormente (OPS65)** — o workflow `agent-pool.yml` foi removido e o dispatch +via CLI falha 404; scripts/skills ficam para histórico. O que foi: +Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` + +dispatch. Workers PR `--base main`. Default model `composer-2.5`. Doctor: +`pnpm agent:pool -- doctor`. Skill: `agent-pool`. ## Guardrails / leitura diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index fc864fa4..89dde030 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -55,6 +55,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-18):** **OPS65**: CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`check`, runs-on `host`, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Sem mudança → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); com mudança → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (compose já referencia o SHA → skip verde). Fail-open: gate com erro → suite roda (main sempre verificada), deploy espera a próxima janela (auto-cura ≤30 min); deploy duplo-gated (`needs: [checks, check]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch), pins do `ciSkipInvariants` (janela 30, sem `push`, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. +**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/changelog/2026-08-18-ops65.md b/docs/changelog/2026-08-18-ops65.md index 6576b17d..5ec5b724 100644 --- a/docs/changelog/2026-08-18-ops65.md +++ b/docs/changelog/2026-08-18-ops65.md @@ -1 +1 @@ -**Recently resolved (2026-08-18):** **OPS65**: CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`check`, runs-on `host`, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Sem mudança → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); com mudança → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (compose já referencia o SHA → skip verde). Fail-open: gate com erro → suite roda (main sempre verificada), deploy espera a próxima janela (auto-cura ≤30 min); deploy duplo-gated (`needs: [checks, check]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch), pins do `ciSkipInvariants` (janela 30, sem `push`, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. +**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index ed5fbb68..8d0eb115 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1,41 +1,54 @@ # Runbook: deploy do site 1313 no homeserver (OPS53, janela OPS65) O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main` -com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual, -em até 30 min após o merge. +com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual. +Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo +da suite/deploy (~15–25 min cada; tipicamente < 1 h no total). ## Gatilho e fluxo 1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via `workflow_dispatch` (deploy manual, imediato, sempre full suite). -2. Job `check` (gate barato, host, sem pnpm): lê o SHA deployado de - `~/stack/docker-compose.yml` no homeserver via `ssh` e classifica "mudança - de produção" com o `.dockerignore` como fonte da verdade (o que não entra - na imagem não justifica build novo; `docs/`, `.agents/`, `.env*` viram - skip; `scripts/`, `tests/`, `.forgejo/` contam como produção — +2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando** + no homeserver (`docker inspect` → label `org.opencontainers.image.revision` + do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback + falho, pois é trocado antes do rollout) e classifica "mudança de produção" + com o `.dockerignore` como fonte da verdade (o que não entra na imagem não + justifica build novo; `docs/`, `.agents/`, `.env*` viram skip; + `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). - - Sem mudança de produção → suite e deploy **skipped**; run verde. - - Gate com erro (ssh/SHA ilegível) → fail-open: suite roda; deploy fica - para a próxima janela (auto-cura em ≤30 min). + - Deployed == HEAD → suite e deploy **skipped**; run verde. + - Sem revision conhecida (label ausente, container sem label) → fail-open: + suite + deploy rodam na janela (run verde; comportamento conservador). + - Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e + o run fica **vermelho** — nunca blackout silencioso. 3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks). 4. Job `deploy` (runs-on `host`, na **workstation**) roda `ssh homeserver "bash -s -- " < scripts/deploy-homeserver.sh`. - O script é **idempotente**: se o compose já referencia o SHA (ex.: dispatch - duplicado), sai verde "already deployed" sem rebuild. + O script é **idempotente**: se o container rodando já tem a revision do SHA + (ex.: dispatch duplicado), sai verde "already deployed" sem rebuild. 5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job, - senão skip) → `flock` (serializa) → guard "already deployed" → build da - imagem standalone + migrator (BuildKit, secrets do `~/stack/teqo-1313.env`, - `--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default` - para o build alcançar o `postgres` — o proxy é criado idempotentemente pelo - script) → push em `localhost:5000` → swap dos tags de imagem no - `~/stack/docker-compose.yml` (backup antes) → **migrations** + senão skip) → `flock` (serializa) → guard "already deployed" (revision do + container) → build da imagem standalone + migrator (BuildKit, secrets do + `~/stack/teqo-1313.env`, `--network host` com proxy socat + `teqo-1313-build-proxy` na `stack_default` para o build alcançar o + `postgres` — o proxy é criado idempotentemente pelo script) → push em + `localhost:5000` → swap dos tags de imagem no `~/stack/docker-compose.yml` + (backup antes) → **migrations** (`docker compose --profile maintenance run --rm teqo-1313-migrate .outputs` com gate falho +(suite roda; deploy pula; run vermelho). + ## Onde roda cada coisa | Máquina | Papel | @@ -74,23 +87,27 @@ roda após build+push ok; rollout só após migrate ok). ## Falhas conhecidas -| Sintoma | Causa | Tratamento | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya | -| Job verde sem deploy ("already deployed") | O compose do homeserver já referencia o 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: teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | -| Gate `check` falha (ssh homeserver / SHA ilegível) | Tailnet fora ou compose ilegível | Fail-open: suite roda; deploy espera a próxima janela (auto-cura em ≤30 min se houver mudança de produção). Se persistir, checar `ssh homeserver` da workstation | -| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) | -| 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 que vai no pipe do `bash -s`; 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 `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, 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 | -| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) | -| 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 | +| Sintoma | Causa | Tratamento | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya | +| 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: teqo-1313:-rebuild`) e `docker compose up -d`, ou espere um commit novo | +| Gate `gate` vermelho (erro de git/`.dockerignore`) | SHA deployado ilegível para o git (ex.: história reescrita) ou `.dockerignore` corrompido no checkout | Fail-open: suite roda; deploy pula (nunca deploy às cegas). Corrigir a causa e esperar a próxima janela (auto-cura). Se `ssh homeserver` estiver fora, o gate lê revision vazia e roda tudo (verde) — checar tailnet se a suite roda sem deploy recorrente | +| Sem revision no container (label ausente no compose) | O compose do homeserver não define `org.opencontainers.image.revision` (deploy pré-OPS53 ou edição manual) | Degradação conservadora: toda janela roda suite + rebuild (verde). Corrigir adicionando o label ao serviço `teqo-1313` no compose e re-deployando | +| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) | +| 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 que vai no pipe do `bash -s`; 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 `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, 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 | +| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) | +| 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 | ## Segurança (decisão deliberada) - O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation. - Está **apenas** no `ci.yml` (push em `main` + dispatch), nunca no `ci-pr.yml`. + Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no + `ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a + `github.ref == 'refs/heads/main'` (dispatch em branch não roda código do + branch no host — a suite roda fail-open em containers com secrets de teste). `main` só anda por PR mergeado com suite verde. Fork-PRs não disparam workflows no Forgejo (default off — verificado). - Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` / diff --git a/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md b/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md index 06c9e709..ab125f61 100644 --- a/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md +++ b/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md @@ -67,13 +67,17 @@ flowchart LR homeserver` (a chave vive no host da workstation — mesmo padrão do job `deploy`, OPS53). Segurança: job só em `ci.yml` (main + dispatch), nunca em `ci-pr.yml` — mesma premissa já validada no OPS53. -2. **Fail-open no gate:** `ssh` falha / SHA deployado ilegível → `production = -true` (suite roda; o site continua verificado). No gate com erro, o deploy - **não** roda (nunca deployar no escuro) e a próxima janela (≤30 min) - auto-cura se houver mudança de produção. Condição dos jobs da suite: - `needs.check.result != 'success' || needs.check.outputs.production == 'true'`. -3. **Deploy condicionado duplo:** `needs: [checks, check]` + `if: -needs.checks.result == 'success' && needs.check.outputs.production == +2. **Fail-open no gate:** o gate lê a **revision do container rodando** no + homeserver (`docker inspect` → label `org.opencontainers.image.revision`; + o compose pode mentir após rollback falho, pois é trocado antes do rollout). + Revision desconhecida (label ausente/container sem label) → `production = +true` (suite roda, run verde). Gate **incapaz de classificar** (erro de + git/`.dockerignore`/sem input) → imprime `production=true` e sai com exit + não-zero → o run fica **vermelho** (nunca blackout silencioso). Deploy + nunca roda no escuro: condição dos jobs da suite é + `needs.gate.result != 'success' || needs.gate.outputs.production == 'true'`. +3. **Deploy condicionado duplo:** `needs: [checks, gate]` + `if: +needs.checks.result == 'success' && needs.gate.outputs.production == 'true'` — sem isso, um `if` só no output rodaria deploy com suite vermelha (semântica do Actions: `if` verdadeiro vence `needs` falho). 4. **Rollup `checks` intocado:** `if: always()` + `all(success or skipped)` já @@ -81,31 +85,37 @@ needs.checks.result == 'success' && needs.check.outputs.production == 5. **Classificador:** matcher de `.dockerignore` (moby semantics: padrão sem `/` casa basename em qualquer profundidade; `!` negação; `/` ancorado à raiz; `/dir/` só diretório; `**`) + CLI `scripts/ci-classify-production.mjs` - (`--deployed `, `--value`; `GITHUB_EVENT_NAME == workflow_dispatch` → - produção forçada). Sem pnpm, sem deps. + (`--deployed `/`--files-from`, `--value`; dispatch → produção forçada; + exit 0 = classificou, exit 1 = não classificou). Sem pnpm, sem deps. 6. **Guard "already deployed" no `deploy-homeserver.sh`:** após o HEAD guard + - flock, antes de sourcear secrets: se o compose já referencia - `image: teqo-1313:$SHA` → `exit 0` ("nothing to do"). Mata o rebuild de ~15 - min em dispatch duplicado. Sem flag FORCE (YAGNI; força = fluxo manual do - runbook). + flock, antes de sourcear secrets: se o **container rodando** já tem a + revision do SHA → `exit 0` ("nothing to do"). Mata o rebuild de ~15 min em + dispatch duplicado. Container sem label/fora = "não deployado" (procede — + direção segura). Sem flag FORCE (YAGNI; força = fluxo manual do runbook). 7. **Remoção do `on: push`:** branch protection do main exige apenas `CI (PR) / checks*` (cascade do `ci-pr.yml`) — merges não dependem do `ci.yml` pós-merge (verificado em `scripts/configure-branch-protection.mjs` e `lib/branch-protection.mjs`). `agent-pr-ready-automerge` e `issue-done-on-main-merge` são eventos de PR — intocados. -8. **`fetch-depth: 0` no job `check`** — o diff `..HEAD` precisa do +8. **`fetch-depth: 0` no job `gate`** — o diff `..HEAD` precisa do histórico; custo de 1 clone full a cada 30 min é desprezível vs a suite. Overlap de janelas (suite > 30 min, sem `concurrency` no Forgejo): - limitado e auto-dedup via SHA deployado (a segunda janela vê deploy == HEAD - → skip); flock serializa deploy. Documentado como limite conhecido. + limitado e auto-dedup via revision deployada (a segunda janela vê + deploy == HEAD → skip); flock serializa deploy. Documentado como limite + conhecido. +9. **Hardening pós-review:** job `gate` com `actions/setup-node@v5` (node 24 + explícito no host), restrito a `github.ref == 'refs/heads/main'` (dispatch + em branch não roda código do branch no host — a suite roda fail-open em + containers com secrets de teste), e invocação única do classificador + (JSON capturado uma vez; `production` extraído via `node -pe`). ### Componentes / mudanças - **`.forgejo/workflows/ci.yml`**: `on:` vira `schedule */30` + - `workflow_dispatch` (remove `push`); job `check` novo (host, 10 min, - outputs `production`/`deployed_sha`); `static/int/build/e2e` ganham `needs: -[check]` + `if` fail-open; `deploy` ganha `needs: [checks, check]` + `if` - duplo. Cabeçalho atualizado. + `workflow_dispatch` (remove `push`); job `gate` novo (host, main-only, + output `production`); `static/int/build/e2e` ganham `needs: [gate]` + `if` + fail-open; `deploy` ganha `needs: [checks, gate]` + `if` duplo. Cabeçalho + atualizado. - **`.forgejo/workflows/agent-pool.yml`**: **removido** (scripts/skills/docs do pool ficam dormentes — aceite). - **`scripts/lib/dockerignore.mjs`** (novo): `parseDockerignore` / diff --git a/scripts/ci-classify-production.mjs b/scripts/ci-classify-production.mjs index e9deba7e..b7ab0935 100644 --- a/scripts/ci-classify-production.mjs +++ b/scripts/ci-classify-production.mjs @@ -6,9 +6,12 @@ * * The `.dockerignore` is the source of truth (the Dockerfile does `COPY . .`): * a changed path is production when it is NOT excluded by the ignore - * patterns. The gate FAILS CLOSED toward "production": dispatch (explicit - * operator intent), an unknown deployed SHA, an empty diff vs the deployed - * SHA being unreadable — any doubt runs the full suite. + * patterns. The gate is FAIL-OPEN toward production: dispatch (explicit + * operator intent) and an unknown deployed SHA always run the full suite; + * a classification ERROR (git/.dockerignore unreadable, no input) prints + * production=true AND exits 1, so the workflow run turns red — the suite + * still runs (fail-open) but the deploy never happens silently. A green run + * only ever means "the gate could classify and there is nothing to do". * * Plain Node, stdlib only — the gate must stay cheap (no pnpm install). * @@ -16,8 +19,8 @@ * node scripts/ci-classify-production.mjs --files-from [--value] * * Output: JSON { production, reason, changed, kept, skipped } on stdout - * (`--value` prints only `true`/`false`). Errors never fail the job: they - * log to stderr, report production=true and exit 0 — the suite runs. + * (`--value` prints only `true`/`false`). Exit 0 = classified; exit 1 = + * could not classify (production=true printed, run should go red). */ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' @@ -70,7 +73,15 @@ const report = (production, reason, extra = {}) => { console.log(JSON.stringify(result)) } -const failOpen = (reason) => report(true, reason, { changed: [], kept: [], skipped: [] }) +/** + * Could not classify at all (git/.dockerignore unreadable, no input): run + * the full pipeline AND make the run red — a gate that cannot classify must + * never look like "nothing to do". + */ +const failOpenRed = (reason) => { + report(true, reason, { changed: [], kept: [], skipped: [] }) + process.exitCode = 1 +} // Import-safe: the module is unit-tested (classifyProduction), so the CLI // body only runs when executed as a script. @@ -90,6 +101,19 @@ function main() { try { if (filesFrom) { changed = readFileSync(filesFrom, 'utf8').split('\n').filter(Boolean) + } else if (deployed === '') { + // The workflow always passes --deployed (possibly empty: no container + // revision on the homeserver). An empty deployed SHA is a legitimate + // "unknown deployed state" — run the full pipeline, keep the run green. + report(true, 'sem SHA deployado (estado desconhecido)', { + changed: [], + kept: [], + skipped: [], + }) + return + } else if (deployed === true) { + failOpenRed('--deployed sem valor') + return } else if (deployed) { changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], { encoding: 'utf8', @@ -97,16 +121,16 @@ function main() { .split('\n') .filter(Boolean) } else { - failOpen('sem SHA deployado nem files-from') + failOpenRed('sem SHA deployado nem files-from') return } } catch (error) { - failOpen(`erro ao obter o diff (${error instanceof Error ? error.message : error})`) + failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`) return } if (changed.length === 0) { - report(false, 'deployed_sha == HEAD (nada a fazer)', { + report(false, 'diff vazio (nada a fazer)', { changed: [], kept: [], skipped: [], @@ -118,7 +142,7 @@ function main() { try { rules = parseDockerignore(readFileSync('.dockerignore', 'utf8')) } catch (error) { - failOpen(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`) + failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`) return } diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index 4fce8527..00ebe972 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -53,14 +53,16 @@ if [ "$main_head" != "$SHA" ]; then fi # --- idempotency (OPS65) ------------------------------------------------ -# The 30-min main window can re-deliver a SHA the compose already references -# (e.g. a duplicate workflow_dispatch): rebuilding it is a ~15 min no-op. -# The compose is the pipeline's source of truth for "deployed" — it is -# swapped atomically-ish before the rollout and restored on rollback. +# A 30-min main window can re-deliver a SHA the cluster already runs (e.g. a +# duplicate workflow_dispatch): rebuilding it is a ~15 min no-op. The truth +# is the RUNNING container's revision label — the compose file can lie after +# a failed rollback (it is swapped before the rollout and restored best- +# effort). A container without the label (or down) counts as "not deployed": +# the deploy proceeds (safe direction — it rebuilds). -compose="$STACK_DIR/docker-compose.yml" -if [ -f "$compose" ] && grep -q "image: teqo-1313:$SHA" "$compose"; then - say "already deployed: compose references $SHA — nothing to do" +running_rev="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' teqo-1313 2>/dev/null || true)" +if [ -n "$running_rev" ] && [ "$running_rev" = "$SHA" ]; then + say "already deployed: teqo-1313 runs $SHA — nothing to do" exit 0 fi diff --git a/tests/unit/ciClassifyProduction.unit.spec.ts b/tests/unit/ciClassifyProduction.unit.spec.ts index 0d29b8ef..7687a6b4 100644 --- a/tests/unit/ciClassifyProduction.unit.spec.ts +++ b/tests/unit/ciClassifyProduction.unit.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,19 +17,65 @@ const realRules = parseDockerignore(readFileSync(join(repoRoot, '.dockerignore') let fixturesDir: string let filesFrom: string +let gitRepo: string +let fixtureShas: { baseSha: string; prodSha: string; docsSha: string } beforeAll(() => { fixturesDir = mkdtempSync(join(tmpdir(), 'ci-classify-production-')) filesFrom = join(fixturesDir, 'changed.txt') + gitRepo = join(fixturesDir, 'repo') + execFileSync('git', ['init', '-q', '-b', 'main', gitRepo]) + writeFileSync(join(gitRepo, '.dockerignore'), 'docs\n') + writeFileSync(join(gitRepo, 'README.md'), 'a\n') + const commit = (message: string) => + execFileSync('git', [ + '-C', + gitRepo, + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + 'commit', + '-q', + '-m', + message, + ]) + execFileSync('git', ['-C', gitRepo, 'add', '-A']) + commit('base') + const baseSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + execFileSync('mkdir', ['-p', join(gitRepo, 'src'), join(gitRepo, 'docs')]) + writeFileSync(join(gitRepo, 'src', 'a.ts'), 'x\n') + writeFileSync(join(gitRepo, 'docs', 'x.md'), 'x\n') + execFileSync('git', ['-C', gitRepo, 'add', '-A']) + commit('prod change') + const prodSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + writeFileSync(join(gitRepo, 'docs', 'y.md'), 'x\n') + execFileSync('git', ['-C', gitRepo, 'add', '-A']) + commit('docs only') + const docsSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + fixtureShas = { baseSha, prodSha, docsSha } }) afterAll(() => { execFileSync('rm', ['-rf', fixturesDir]) }) -const runCli = (args: string[]) => { +const fixture = () => fixtureShas + +const runCli = (args: string[], options: { cwd?: string } = {}) => { const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' } - return execFileSync('node', [cliPath, ...args], { encoding: 'utf8', env }) + const result = spawnSync('node', [cliPath, ...args], { + encoding: 'utf8', + env, + cwd: options.cwd, + }) + return { out: result.stdout.trim(), status: result.status } } const writeChanged = (paths: string[]) => writeFileSync(filesFrom, paths.join('\n')) @@ -59,7 +105,9 @@ describe('classifyProduction (pure)', () => { describe('ci-classify-production CLI (OPS65 gate)', () => { it('reports JSON with production, changed, kept and skipped', () => { writeChanged(['docs/plans/x.md', 'src/lib/foo.ts']) - const result = JSON.parse(runCli(['--files-from', filesFrom])) + const { out, status } = runCli(['--files-from', filesFrom]) + expect(status).toBe(0) + const result = JSON.parse(out) expect(result.production).toBe(true) expect(result.kept).toEqual(['src/lib/foo.ts']) expect(result.skipped).toEqual(['docs/plans/x.md']) @@ -67,37 +115,66 @@ describe('ci-classify-production CLI (OPS65 gate)', () => { it('--value prints only the boolean', () => { writeChanged(['docs/plans/x.md']) - expect(runCli(['--files-from', filesFrom, '--value']).trim()).toBe('false') + expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('false') writeChanged(['src/app/page.tsx']) - expect(runCli(['--files-from', filesFrom, '--value']).trim()).toBe('true') + expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('true') }) it('an empty diff means nothing to do (production=false)', () => { writeChanged([]) - const result = JSON.parse(runCli(['--files-from', filesFrom])) + const { out, status } = runCli(['--files-from', filesFrom]) + expect(status).toBe(0) + const result = JSON.parse(out) expect(result.production).toBe(false) expect(result.reason).toContain('nada a fazer') }) - it('fails open when the deployed SHA is unknown', () => { - const result = JSON.parse(runCli(['--deployed', ''])) + it('classifies a real git diff against the deployed revision', () => { + const { baseSha, prodSha, docsSha } = fixture() + // HEAD is the docs-only commit; from the base SHA the diff mixes the + // production change (src/a.ts) with the docs-only commits. + const prod = JSON.parse(runCli(['--deployed', baseSha], { cwd: gitRepo }).out) + expect(prod.production).toBe(true) + expect(prod.changed).toEqual(['docs/x.md', 'docs/y.md', 'src/a.ts']) + expect(prod.kept).toEqual(['src/a.ts']) + expect(prod.skipped).toEqual(['docs/x.md', 'docs/y.md']) + + const docs = JSON.parse(runCli(['--deployed', prodSha], { cwd: gitRepo }).out) + expect(docs.production).toBe(false) + expect(docs.skipped).toEqual(['docs/y.md']) + + const same = JSON.parse(runCli(['--deployed', docsSha], { cwd: gitRepo }).out) + expect(same.production).toBe(false) + }) + + it('keeps the run green when the deployed SHA is unknown (empty)', () => { + const { out, status } = runCli(['--deployed', '']) + expect(status).toBe(0) + const result = JSON.parse(out) expect(result.production).toBe(true) expect(result.reason).toContain('sem SHA deployado') }) - it('fails open without any input at all', () => { - const result = JSON.parse(runCli([])) - expect(result.production).toBe(true) + it('turns the run red when it cannot classify (no input)', () => { + const { out, status } = runCli([]) + expect(status).not.toBe(0) + expect(JSON.parse(out).production).toBe(true) }) - it('fails open on an unreadable .dockerignore', () => { - writeChanged(['src/app/page.tsx']) - const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' } - const out = execFileSync('node', [cliPath, '--files-from', filesFrom], { - encoding: 'utf8', - env, - cwd: fixturesDir, + it('turns the run red when the deployed revision cannot be diffed', () => { + const { out, status } = runCli(['--deployed', 'ffffffffffffffffffffffffffffffffffffffff'], { + cwd: gitRepo, }) + expect(status).not.toBe(0) + const result = JSON.parse(out) + expect(result.production).toBe(true) + expect(result.reason).toContain('erro ao obter o diff') + }) + + it('turns the run red when the .dockerignore is unreadable', () => { + writeChanged(['src/app/page.tsx']) + const { out, status } = runCli(['--files-from', filesFrom], { cwd: fixturesDir }) + expect(status).not.toBe(0) const result = JSON.parse(out) expect(result.production).toBe(true) expect(result.reason).toContain('.dockerignore ilegível') @@ -105,12 +182,13 @@ describe('ci-classify-production CLI (OPS65 gate)', () => { it('workflow_dispatch always runs the full pipeline', () => { const env = { ...process.env, GITHUB_EVENT_NAME: 'workflow_dispatch' } - const out = execFileSync('node', [cliPath, '--files-from', filesFrom], { + const result = spawnSync('node', [cliPath, '--files-from', filesFrom], { encoding: 'utf8', env, }) - const result = JSON.parse(out) - expect(result.production).toBe(true) - expect(result.reason).toContain('workflow_dispatch') + const parsed = JSON.parse(result.stdout) + expect(result.status).toBe(0) + expect(parsed.production).toBe(true) + expect(parsed.reason).toContain('workflow_dispatch') }) }) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 064b07d5..8570d5f1 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -119,19 +119,21 @@ describe('ciSkipInvariants', () => { expect(ci).toContain('workflow_dispatch:') // The cheap gate (no pnpm) classifies production change and every - // expensive job consumes it, fail-open on gate errors. - expect(ci).toContain('check production change') + // expensive job consumes it, fail-open on gate errors. The gate is + // main-only: a branch dispatch must not run branch code on the host. + expect(ci).toContain('gate production change') + expect(ci).toContain("if: github.ref == 'refs/heads/main'") expect(ci).toContain('fetch-depth: 0') expect(ci).toContain('scripts/ci-classify-production.mjs') expect(ci).toContain( - "needs.check.result != 'success' || needs.check.outputs.production == 'true'", + "needs.gate.result != 'success' || needs.gate.outputs.production == 'true'", ) // Deploy is double-gated: green rollup AND production change (never // deploy blind, never deploy with a red suite). - expect(ci).toContain('needs: [checks, check]') + expect(ci).toContain('needs: [checks, gate]') expect(ci).toContain( - "needs.checks.result == 'success' && needs.check.outputs.production == 'true'", + "needs.checks.result == 'success' && needs.gate.outputs.production == 'true'", ) }) diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 9dca8aff..a6a308f5 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -22,10 +22,12 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { expect(script).toContain('stale run') }) - it('skips idempotently when the compose already references the SHA (OPS65)', () => { + it('skips idempotently when the running container already runs the SHA (OPS65)', () => { expect(script).toContain('already deployed') - expect(script).toContain('image: teqo-1313:$SHA') - expect(script).toContain('docker-compose.yml') + expect(script).toContain('docker inspect') + expect(script).toContain('org.opencontainers.image.revision') + expect(script).toContain('$running_rev') + expect(script).toContain('"$running_rev" = "$SHA"') }) it('builds with BuildKit secrets — prod credentials never enter image layers', () => {