ops(OPS65): CI do main em janela de 30 min (gate .dockerignore fail-open) + matar agent-pool
All checks were successful
CI (PR) / checks (pull_request) Successful in 8m4s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13m48s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 52m5s
All checks were successful
CI (PR) / checks (pull_request) Successful in 8m4s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13m48s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 52m5s
- ci.yml: on push removido → schedule */30 + workflow_dispatch (fundido com a estrutura de job único sequencial do OPS62 X1) - job gate (host, main-only, sem pnpm): revision do container rodando no homeserver (label org.opencontainers.image.revision — o compose pode mentir após rollback falho) + diff + classificador .dockerignore - fail-open: revision desconhecida → suite roda (verde); incapaz de classificar → run vermelho (nunca blackout silencioso); deploy duplo-gated (needs: [checks, gate] + if explícito) - deploy-homeserver.sh: guard 'already deployed' idempotente (mesma revision) - agent-pool.yml removido; pool dormente (144 pnpm install/dia no tick */10) - specs: dockerignoreMatch, ciClassifyProduction (incl. git-diff com repo scratch e exit codes), pins ciSkipInvariants/deployScript; HIGH_RISK_EXACT - docs: runbook (janela/gate/verificação ao vivo/falhas), AGENTS.md, AGENT-OPS.md (tabela X1 fundida), skills pool dormente, changelog OPS65 - impl plan aprovado; intenção marcada entregue
This commit is contained in:
parent
4522fe4b74
commit
9504c7ccf6
19 changed files with 976 additions and 183 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
# Agent pool supervisor (docs/plans/agent-pool-orchestrator.md).
|
||||
#
|
||||
# Stateless tick: reconciles pool claims and spawns Cursor Cloud workers up to
|
||||
# POOL_MAX_SLOTS over `ready` autonomous-eligible issues. Three triggers:
|
||||
# - workflow_dispatch — the canonical human channel (start/stop/pause/resume)
|
||||
# - schedule */10min — the periodic reconcile (default branch only)
|
||||
# - pull_request closed on main — immediate slot replenish after a merge
|
||||
#
|
||||
# Secrets:
|
||||
# CURSOR_API_KEY — Cursor Cloud Agents API key
|
||||
# FORGEJO_API_TOKEN — optional PAT; the native GITHUB_TOKEN (full repo
|
||||
# access) covers issue/label/contents ops inside Forgejo Actions
|
||||
#
|
||||
# Forgejo port (OPS50): no `concurrency:`/`permissions:` blocks; pool state
|
||||
# lives in pool-state.json on the `pool-state` branch (variables API is
|
||||
# GET-only here), read/written by scripts/lib/agent-pool-forgejo.mjs.
|
||||
name: Agent pool supervisor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Ação do pool'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- start
|
||||
- stop
|
||||
- pause
|
||||
- resume
|
||||
- tick
|
||||
- status
|
||||
maxSlots:
|
||||
description: 'Slots simultâneos (só com action=start; default 5, máx 8)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
tick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: agent:pool workflow
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
ACTION: ${{ inputs.action }}
|
||||
MAX_SLOTS: ${{ inputs.maxSlots }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
args=(--action "$ACTION" --actor "$GITHUB_ACTOR")
|
||||
if [ -n "$MAX_SLOTS" ]; then args+=(--max-slots "$MAX_SLOTS"); fi
|
||||
else
|
||||
args=(--action tick --actor "$GITHUB_ACTOR")
|
||||
fi
|
||||
node scripts/agent-pool.mjs workflow "${args[@]}"
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
# Post-merge verifier on `main`: always the FULL suite (no affected skips).
|
||||
# SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm
|
||||
# install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips
|
||||
# every step after it, the job fails, the run fails and the slot is released
|
||||
# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job,
|
||||
# no upgrades. `deploy` (needs: [checks]) never starts on a red run.
|
||||
# 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 (`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.
|
||||
# - 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.
|
||||
#
|
||||
# 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
|
||||
|
|
@ -11,17 +24,24 @@
|
|||
# pushes it to the local registry (localhost:5000), applies pending migrations
|
||||
# through the compose maintenance service and rolls the container out.
|
||||
#
|
||||
# SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm
|
||||
# install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips
|
||||
# every step after it, the job fails, the run fails and the slot is released
|
||||
# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job,
|
||||
# no upgrades. `deploy` (needs: [checks]) never starts on a red run.
|
||||
#
|
||||
# Forgejo port (OPS50): Postgres services live on the job network and are
|
||||
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
|
||||
# so concurrent runs never collide on host ports (OPS62 X1). No
|
||||
# `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no
|
||||
# `concurrency:` (unsupported). `gh workflow run` requeues became
|
||||
# `node scripts/forgejo-dispatch.mjs`.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
|
@ -29,9 +49,50 @@ env:
|
|||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
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
|
||||
# <deployed-rev>..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:
|
||||
production: ${{ steps.classify.outputs.production }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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
|
||||
# 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:-<none>}"
|
||||
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"
|
||||
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
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.gate.result != 'success' || needs.gate.outputs.production == 'true'
|
||||
services:
|
||||
# Services are reached by NAME on the job network (no host publish —
|
||||
# OPS62 X1): concurrent runs never collide on host ports, which the
|
||||
|
|
@ -148,11 +209,16 @@ jobs:
|
|||
# OPS53. Runs directly on the runner HOST (not a job container): the
|
||||
# deploy needs the workstation's SSH key to reach the homeserver
|
||||
# (`ssh homeserver`) and streams the whole remote build/migrate/rollout
|
||||
# to this job's logs. Deliberately wired ONLY here — push to main +
|
||||
# to this job's logs. Deliberately wired ONLY here — main window +
|
||||
# workflow_dispatch — never in ci-pr.yml: a host job is code execution as
|
||||
# `fsolla` on the workstation, and main only ever moves through a merged
|
||||
# PR whose full suite was already green.
|
||||
needs: [checks]
|
||||
# OPS65: double-gated — the verifier must be green AND the gate 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, gate]
|
||||
if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true'
|
||||
runs-on: host
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -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)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script 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. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **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.
|
||||
|
|
|
|||
|
|
@ -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 full suite verde → job `deploy` (homeserver, OPS53 — merge em main == site atualizado)
|
||||
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.
|
||||
|
|
@ -34,19 +34,19 @@ main → ci.yml full suite verde → job `deploy` (homeserver, OPS53 — merge e
|
|||
|
||||
## Comandos
|
||||
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
|
||||
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
|
||||
|
|
@ -69,17 +69,17 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
|
|||
|
||||
## CI por alvo
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | -------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
|
||||
| `ci.yml` | push / dispatch `main` | 2× service Postgres 17 | Full sempre; job único `checks` sequencial (mesma mecânica X1); job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
|
||||
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
|
||||
| `ci.yml` | schedule `*/30` / dispatch `main` | 2× service Postgres 17 | Janela fixa (OPS65): gate `gate` (host, main-only, sem pnpm) classifica mudança de produção desde a **revision do container rodando** no homeserver (`.dockerignore` = fonte da verdade; fail-open; incapaz de classificar → run **vermelho**); sem mudança → suite + deploy skipped, run verde; com mudança → job único `checks` sequencial (mesma mecânica X1) + job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53, guard "already deployed") |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5` e `pnpm/action-setup@v6` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5` e `pnpm/action-setup@v6` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation).
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
|
||||
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
||||
|
||||
|
|
@ -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): o job `deploy` do `ci.yml` roda no host do runner (workstation) e 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. 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): o job `deploy` do `ci.yml` roda no host do runner (workstation)
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).
|
||||
|
||||
**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 -- <sha>"`); 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-<sha>`), **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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-18-ops65.md
Normal file
1
docs/changelog/2026-08-18-ops65.md
Normal file
|
|
@ -0,0 +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 (`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`.
|
||||
|
|
@ -1,27 +1,53 @@
|
|||
# Runbook: deploy do site 1313 no homeserver (OPS53)
|
||||
# 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.
|
||||
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) — ou `workflow_dispatch` no `ci.yml`
|
||||
para re-deployar a HEAD de `main` manualmente.
|
||||
2. Suite completa verde (static/int/build/e2e/checks).
|
||||
3. Job `deploy` (runs-on `host`, na **workstation**) roda
|
||||
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 `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).
|
||||
- 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 -- <sha>" < scripts/deploy-homeserver.sh`.
|
||||
4. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
|
||||
senão skip) → `flock` (serializa) → 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**
|
||||
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" (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 </dev/null`) →
|
||||
`docker compose up -d teqo-1313` → healthcheck → smoke (`/`,
|
||||
`/campanha/login`, `/admin`, barreira 307, WebAuthn login-options,
|
||||
`api/revalidate` com o secret real).
|
||||
5. Falha = job vermelho no commit; nada é publicado pela metade.
|
||||
6. Falha = job vermelho no commit; nada é publicado pela metade.
|
||||
|
||||
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
|
||||
run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
|
||||
o veredito da classificação, e que um dispatch manual roda suite + deploy
|
||||
(idempotência: segundo dispatch no mesmo SHA sai "already deployed"). Isso
|
||||
prova ao vivo a paridade Forgejo de `needs.<gate>.outputs` com gate falho
|
||||
(suite roda; deploy pula; run vermelho).
|
||||
|
||||
## Onde roda cada coisa
|
||||
|
||||
|
|
@ -61,21 +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 |
|
||||
| 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:<sha>-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` /
|
||||
|
|
|
|||
193
docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md
Normal file
193
docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Impl: CI do main em janela de 30 min + matar o agent-pool
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-18
|
||||
Issue: #64
|
||||
Intenção: docs/plans/ops65-ci-main-janela-30min-e-matar-pool.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng; um outcome verificável)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a workstation deixa de pagar a CI do main a cada merge e o tick
|
||||
do agent-pool. `ci.yml` roda por **janela fixa de 30 min** (`schedule */30` +
|
||||
`workflow_dispatch`), com um **gate barato (~segundos, sem pnpm)** que
|
||||
classifica "mudança de produção" comparando o HEAD de `main` com o SHA
|
||||
**deployado** no homeserver; sem mudança → suite + deploy skipped, run verde.
|
||||
Deploy manual (dispatch) continua imediato. `agent-pool.yml` é removido.
|
||||
- **O que NÃO negociar:** não remover a verificação pós-merge do main (o
|
||||
`ci.yml` continua o verificador); deploy continua gated pelo verificador
|
||||
verde; sem segundo mecanismo de deploy fora do pipeline; nada de requeue
|
||||
estilo cooldown (janela fixa é o corte).
|
||||
- **O que reavaliar:** a hipótese de "classificar por `.dockerignore`" é
|
||||
confirmada — e é a única abordagem que **falha fechado** (qualquer dúvida →
|
||||
suite roda; ver anti-goal "não remover a verificação do main"). O exemplo do
|
||||
aceite ("só docs/**scripts**/skills/AGENTS → skip") **não** vale literalmente
|
||||
sob `.dockerignore`: `scripts/`, `tests/`, `.forgejo/` e `AGENTS.md` **estão
|
||||
no build context** (o Dockerfile faz `COPY . .`) → contam como produção.
|
||||
Decisão resolvida pela direção explícita do plano (`.dockerignore` é a fonte
|
||||
da verdade) + anti-goal: a classificação é **conservadora** (mais runs, nunca
|
||||
menos). Só `docs/`, `.agents/`, `.env*`, artefatos etc. viram skip.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M[merge em main] --> S[schedule */30 ou workflow_dispatch]
|
||||
S --> C[check: runs-on host<br/>lê SHA deployado do compose no homeserver<br/>git diff --name-only <deployado> HEAD]
|
||||
C --> G{produção mudou?<br/>dockerignore do build context}
|
||||
G -->|não| SK[suite + deploy skipped<br/>run verde]
|
||||
G -->|sim| SU[suite full]
|
||||
SU --> CH[checks rollup]
|
||||
CH --> D[deploy: já deployado?<br/>guard idempotente]
|
||||
D -->|compose já no SHA| DONE[skip verde]
|
||||
D -->|SHA novo| DEP[build + migrate + rollout homeserver]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A) Gate `check` (runs-on `host`, ~segundos):** checkout com `fetch-depth: 0`
|
||||
→ `ssh homeserver` lê o SHA deployado do `~/stack/docker-compose.yml`
|
||||
(`image: teqo-1313:<sha>`) → `git diff --name-only <sha> HEAD` →
|
||||
`scripts/ci-classify-production.mjs` classifica pelos padrões do
|
||||
`.dockerignore` (semântica moby, matcher puro em Node stdlib, sem pnpm).
|
||||
Job `deploy` ganha guard de idempotência no script remoto ("compose já
|
||||
referencia o SHA → exit 0"). Recomendada.
|
||||
- **B) Gate comparando `HEAD` vs último SHA conhecido do run anterior**
|
||||
(estado em arquivo na workstation / Forgejo API). Rejeitada — o estado
|
||||
real da produção vive no homeserver; derivar de artefatos de CI mente
|
||||
quando um run falha no meio (compose restaurado no rollback = a verdade).
|
||||
- **C) Whitelist manual de "caminhos de produção"** (src/, public/, Dockerfile…).
|
||||
Rejeitada — drift e **falha aberto**: um caminho esquecido → suite pulada
|
||||
indevidamente → main não verificado, exatamente o anti-goal. `.dockerignore`
|
||||
é auto-mantido e é o que define o artefato.
|
||||
|
||||
**Decisões de engenharia (com rejeitadas):**
|
||||
|
||||
1. **Gate `check` em `runs-on: host`** (não container): precisa de `ssh
|
||||
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:** 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á
|
||||
aceita jobs skipped → run verde com chips skipped quando nada a fazer.
|
||||
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 <sha>`/`--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 **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 `gate`** — o diff `<deployado>..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 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 `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` /
|
||||
`isIgnoredPath` — matcher puro de `.dockerignore` (moby semantics), stdlib.
|
||||
- **`scripts/ci-classify-production.mjs`** (novo): CLI — dispatch → true;
|
||||
SHA deployado ausente → true; senão `git diff --name-only <sha> HEAD` →
|
||||
classifica; JSON no stdout (`--value` para o workflow); erro interno → true
|
||||
- stderr (fail-open), exit 0.
|
||||
- **`scripts/deploy-homeserver.sh`**: guard "already deployed" (compose já
|
||||
referencia o SHA → skip verde) após o flock, antes dos secrets.
|
||||
- **`scripts/lib/test-affected-core.mjs`**: `HIGH_RISK_EXACT` +=
|
||||
`scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`
|
||||
(mudar o classificador força suite full no PR — padrão existente).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** N/A.
|
||||
- **UI:** Impeccable A — sem UI.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — política de infra/CI.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Classificador core** — `lib/dockerignore.mjs` + `ci-classify-production.mjs`
|
||||
- specs unit (matcher contra o `.dockerignore` real; classify pure; CLI via
|
||||
`--files-from`; fail-open: sem SHA deployado / dispatch / erro → true).
|
||||
2. **Workflow + pool** — `ci.yml` (schedule + gate + ifs), delete
|
||||
`agent-pool.yml`, `HIGH_RISK_EXACT`, pins do `ciSkipInvariants` (janela 30,
|
||||
sem `push`, gate existe, ifs fail-open) e `deployScript` (guard
|
||||
"already deployed").
|
||||
3. **Docs + changelog** — runbook `docs/ops/teqo-1313-deploy.md` (janela,
|
||||
gate, "already deployed", falhas conhecidas: ssh do gate falha → fail-open;
|
||||
overlap), `AGENTS.md` (parágrafo CI/deploy), `docs/AGENT-OPS.md` (linhas de
|
||||
`ci.yml` e `agent-pool.yml`), `docs/changelog/2026-08-18-ops65.md` +
|
||||
`pnpm changelog:build`, status `entregue` no plano de intenção.
|
||||
4. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm knip`,
|
||||
`pnpm check:cycles`, `pnpm build` local. Sem mudança em `src/` → e2e do PR
|
||||
skip por affected.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Reimplementar cooldown GitHub (defer/requeue) — corte do plano; janela fixa.
|
||||
- Classificação fina por tipo de arquivo (whitelist) — corte do plano;
|
||||
`.dockerignore` é a fonte da verdade.
|
||||
- Flag FORCE no deploy / UI de deploy — runbook basta.
|
||||
- Limpar scripts/skills/docs do pool — dormentes por aceite.
|
||||
- Mover a CI para VPS/homeserver — fora de escopo do plano.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **`ssh homeserver` falha no gate** → fail-open (suite roda); deploy fica para
|
||||
a próxima janela (≤30 min, auto-cura). Runbook documenta.
|
||||
- **Overlap de janelas** (suite > 30 min) → segunda suite no mesmo SHA
|
||||
(raro, limitado); flock serializa; SHA deployado dedupa a janela seguinte.
|
||||
- **Dispatch do mesmo SHA** → suite roda (intenção explícita), deploy skip
|
||||
verde "already deployed"; força via fluxo manual do runbook.
|
||||
- **Remoção do `push` → "merge != deploy imediato"** → aceite explícito da
|
||||
intenção (≤30 min); dispatch cobre urgência; falha de deploy continua
|
||||
vermelha e o site fica no container antigo.
|
||||
- **Deploy antigo sobrescrevendo push novo** → HEAD guard + flock intactos.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (janela 30 min; run verde
|
||||
com skipped; pool morto; dispatch imediato; deploy gated)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem segredos novos; guards de
|
||||
dev/test intocados; nada de Neon; host jobs só em ci.yml)
|
||||
- [x] Testes previstos (unit: matcher + classificador + pins de workflow e
|
||||
script de deploy)
|
||||
|
||||
Self-score decision-quality: 1) decisões caras com rejeitadas ✓ 2) cabe no
|
||||
appetite ✓ 3) rabbit holes nomeados ✓ 4) reusa padrões existentes (OPS53 host
|
||||
job + ssh, flock, HEAD guard, ciSkipInvariants/deployScript pins, HIGH_RISK) ✓ 5) aceite da intenção preservado, com uma divergência explícita de exemplo do
|
||||
aceite (scripts/tests/AGENTS contam como produção por `.dockerignore` —
|
||||
conservador, alinhado ao anti-goal) ✓ — 5/5.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# CI do main em janela de 30 min + matar o agent-pool
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue
|
||||
Atualizado em: 2026-08-18
|
||||
Issue: #64
|
||||
Priority: P0
|
||||
|
|
|
|||
155
scripts/ci-classify-production.mjs
Normal file
155
scripts/ci-classify-production.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ci-classify-production — OPS65 production-change gate for the main CI
|
||||
* window. Classifies whether the merge head differs from the deployed
|
||||
* artifact in anything that would enter the docker build context.
|
||||
*
|
||||
* 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 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).
|
||||
*
|
||||
* node scripts/ci-classify-production.mjs --deployed <sha> [--value]
|
||||
* node scripts/ci-classify-production.mjs --files-from <file> [--value]
|
||||
*
|
||||
* Output: JSON { production, reason, changed, kept, skipped } on stdout
|
||||
* (`--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'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { isDockerignored, parseDockerignore } from './lib/dockerignore.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const deployed = flags.deployed
|
||||
const filesFrom = flags.filesFrom
|
||||
const valueOnly = flags.value === true
|
||||
|
||||
/**
|
||||
* @param {string[]} changed - repo-relative changed paths
|
||||
* @param {import('./lib/dockerignore.mjs').DockerignoreRule[]} patterns -
|
||||
* .dockerignore rules
|
||||
* @returns {{ production: boolean, kept: string[], skipped: string[] }}
|
||||
*/
|
||||
export function classifyProduction(changed, patterns) {
|
||||
const kept = []
|
||||
const skipped = []
|
||||
for (const path of changed) {
|
||||
if (path.length === 0) continue
|
||||
if (isDockerignored(path, patterns)) skipped.push(path)
|
||||
else kept.push(path)
|
||||
}
|
||||
return { production: kept.length > 0, kept, skipped }
|
||||
}
|
||||
|
||||
const report = (production, reason, extra = {}) => {
|
||||
const result = { production, reason, ...extra }
|
||||
if (valueOnly) {
|
||||
console.log(production)
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
const isCli =
|
||||
typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isCli) {
|
||||
main()
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
|
||||
report(true, 'workflow_dispatch (intenção explícita)')
|
||||
return
|
||||
}
|
||||
|
||||
let changed
|
||||
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',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
failOpenRed('sem SHA deployado nem files-from')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
report(false, 'diff vazio (nada a fazer)', {
|
||||
changed: [],
|
||||
kept: [],
|
||||
skipped: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let rules
|
||||
try {
|
||||
rules = parseDockerignore(readFileSync('.dockerignore', 'utf8'))
|
||||
} catch (error) {
|
||||
failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`)
|
||||
return
|
||||
}
|
||||
|
||||
const classified = classifyProduction(changed, rules)
|
||||
report(
|
||||
classified.production,
|
||||
classified.production ? 'mudança de produção desde o último deploy' : 'sem mudança de produção',
|
||||
{ changed, kept: classified.kept, skipped: classified.skipped },
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +52,20 @@ if [ "$main_head" != "$SHA" ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# --- idempotency (OPS65) ------------------------------------------------
|
||||
# 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).
|
||||
|
||||
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
|
||||
|
||||
# --- secrets (sourced locally, never echoed) ----------------------------
|
||||
|
||||
set -a
|
||||
|
|
|
|||
106
scripts/lib/dockerignore.mjs
Normal file
106
scripts/lib/dockerignore.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* dockerignore match — pure `.dockerignore` semantics (moby patternmatcher
|
||||
* subset) for the CI production-change gate (OPS65). No git, no fs, no deps.
|
||||
*
|
||||
* The gate classifies "production change" by asking: would this path enter the
|
||||
* docker build context? The `.dockerignore` is the source of truth for the
|
||||
* artifact (the Dockerfile does `COPY . .`), so a path is production when it
|
||||
* is NOT excluded by the ignore patterns.
|
||||
*
|
||||
* Implemented rules (moby semantics):
|
||||
* - blank lines and `#` comments are ignored
|
||||
* - `!` prefixes a negation (re-include); last match wins
|
||||
* - trailing `/` is a directory pattern (the dir itself and its tree)
|
||||
* - leading `/` anchors the pattern to the context root
|
||||
* - a pattern with no other `/` matches the basename at ANY depth
|
||||
* - any matched path also excludes everything under it (the builder prunes
|
||||
* at the matched directory, so `docs` excludes `docs/x.md`)
|
||||
* - `*` matches any run of non-separator chars, `?` a single one
|
||||
* - `**` matches any number of directories (globstar)
|
||||
*/
|
||||
|
||||
/** A compiled ignore rule: regex against the cleaned repo-relative path. */
|
||||
export class DockerignoreRule {
|
||||
/**
|
||||
* @param {RegExp} regex - compiled matcher
|
||||
* @param {boolean} negate - `!` prefix: re-include when matched
|
||||
* @param {string} source - original pattern text (diagnostics)
|
||||
*/
|
||||
constructor(regex, negate, source) {
|
||||
this.regex = regex
|
||||
this.negate = negate
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern - one `.dockerignore` line (no trailing newline)
|
||||
* @returns {DockerignoreRule}
|
||||
*/
|
||||
export function compileDockerignoreRule(pattern) {
|
||||
const source = pattern
|
||||
let negate = false
|
||||
if (pattern.startsWith('!')) {
|
||||
negate = true
|
||||
pattern = pattern.slice(1)
|
||||
}
|
||||
if (pattern.endsWith('/')) pattern = pattern.slice(0, -1)
|
||||
const anchored = pattern.startsWith('/')
|
||||
if (anchored) pattern = pattern.slice(1)
|
||||
|
||||
let body = ''
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index]
|
||||
if (char === '*') {
|
||||
if (pattern[index + 1] === '*') {
|
||||
index += 1
|
||||
if (pattern[index + 1] === '/') {
|
||||
body += '(?:.*/)?'
|
||||
index += 1
|
||||
} else {
|
||||
body += '.*'
|
||||
}
|
||||
} else {
|
||||
body += '[^/]*'
|
||||
}
|
||||
} else if (char === '?') {
|
||||
body += '[^/]'
|
||||
} else if ('\\^$.|+()[]{}'.includes(char)) {
|
||||
body += `\\${char}`
|
||||
} else {
|
||||
body += char
|
||||
}
|
||||
}
|
||||
|
||||
const anyDepth = !anchored && !pattern.includes('/') ? '(?:.*/)?' : ''
|
||||
const subtree = '(?:/.*)?'
|
||||
const regexSource = body.length > 0 ? `^${anyDepth}${body}${subtree}$` : '$^'
|
||||
return new DockerignoreRule(new RegExp(regexSource), negate, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text - full `.dockerignore` content
|
||||
* @returns {DockerignoreRule[]} rules in order (last match wins)
|
||||
*/
|
||||
export function parseDockerignore(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.map(compileDockerignoreRule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-match-wins exclusion test for one repo-relative path.
|
||||
*
|
||||
* @param {string} path - repo-relative, forward slashes (e.g. "src/app/x.ts")
|
||||
* @param {DockerignoreRule[]} rules - from parseDockerignore
|
||||
* @returns {boolean} true when the path is excluded from the build context
|
||||
*/
|
||||
export function isDockerignored(path, rules) {
|
||||
let ignored = false
|
||||
for (const rule of rules) {
|
||||
if (rule.regex.test(path)) ignored = !rule.negate
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'scripts/e2e-affected.mjs',
|
||||
'scripts/run-e2e-affected.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/ci-classify-production.mjs',
|
||||
'scripts/lib/dockerignore.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/check-plans-only-pr-closes.mjs',
|
||||
'scripts/lib/plansOnlyClosesGuard.mjs',
|
||||
|
|
|
|||
194
tests/unit/ciClassifyProduction.unit.spec.ts
Normal file
194
tests/unit/ciClassifyProduction.unit.spec.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { classifyProduction } from '../../scripts/ci-classify-production.mjs'
|
||||
import { parseDockerignore } from '../../scripts/lib/dockerignore.mjs'
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
||||
const cliPath = join(repoRoot, 'scripts/ci-classify-production.mjs')
|
||||
const realRules = parseDockerignore(readFileSync(join(repoRoot, '.dockerignore'), 'utf8'))
|
||||
|
||||
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 fixture = () => fixtureShas
|
||||
|
||||
const runCli = (args: string[], options: { cwd?: string } = {}) => {
|
||||
const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' }
|
||||
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'))
|
||||
|
||||
describe('classifyProduction (pure)', () => {
|
||||
it('keeps paths the docker build context receives', () => {
|
||||
const { production, kept, skipped } = classifyProduction(
|
||||
['src/app/page.tsx', 'docs/plans/x.md', 'package.json'],
|
||||
realRules,
|
||||
)
|
||||
expect(production).toBe(true)
|
||||
expect(kept).toEqual(['src/app/page.tsx', 'package.json'])
|
||||
expect(skipped).toEqual(['docs/plans/x.md'])
|
||||
})
|
||||
|
||||
it('reports no production change when every path is ignored', () => {
|
||||
const { production, kept, skipped } = classifyProduction(
|
||||
['docs/ops/teqo-1313-deploy.md', '.agents/skills/foo/SKILL.md'],
|
||||
realRules,
|
||||
)
|
||||
expect(production).toBe(false)
|
||||
expect(kept).toEqual([])
|
||||
expect(skipped).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
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 { 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'])
|
||||
})
|
||||
|
||||
it('--value prints only the boolean', () => {
|
||||
writeChanged(['docs/plans/x.md'])
|
||||
expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('false')
|
||||
writeChanged(['src/app/page.tsx'])
|
||||
expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('true')
|
||||
})
|
||||
|
||||
it('an empty diff means nothing to do (production=false)', () => {
|
||||
writeChanged([])
|
||||
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('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('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('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')
|
||||
})
|
||||
|
||||
it('workflow_dispatch always runs the full pipeline', () => {
|
||||
const env = { ...process.env, GITHUB_EVENT_NAME: 'workflow_dispatch' }
|
||||
const result = spawnSync('node', [cliPath, '--files-from', filesFrom], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
})
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
expect(result.status).toBe(0)
|
||||
expect(parsed.production).toBe(true)
|
||||
expect(parsed.reason).toContain('workflow_dispatch')
|
||||
})
|
||||
})
|
||||
|
|
@ -75,56 +75,6 @@ describe('ciSkipInvariants', () => {
|
|||
expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts')
|
||||
})
|
||||
|
||||
it('OPS62 X1: single sequential `checks` job with structural fail-fast', () => {
|
||||
for (const file of ['.forgejo/workflows/ci.yml', '.forgejo/workflows/ci-pr.yml']) {
|
||||
const source = readFileSync(join(repoRoot, file), 'utf8')
|
||||
// Exactly one job id `checks` — the status context automerge/branch
|
||||
// protection wait on. Fail-fast is structural: no siblings, no matrix,
|
||||
// no strategy fail-fast (anchored as YAML keys so comments can't trip).
|
||||
expect(source.match(/^jobs:\n checks:\n/m) ?? [], file).toHaveLength(1)
|
||||
expect(source, file).not.toMatch(/^ {2,4}(?:matrix|fail-fast):/m)
|
||||
// e2e runs as one process with 4 workers (replaces the 2-shard matrix).
|
||||
expect(source, file).toContain('PLAYWRIGHT_WORKERS: 4')
|
||||
// Steps keep their own logs so diagnosis survives a failure.
|
||||
expect(source, file).toContain('name: Lint')
|
||||
expect(source, file).toContain('name: E2E tests')
|
||||
}
|
||||
|
||||
const pr = readFileSync(join(repoRoot, '.forgejo/workflows/ci-pr.yml'), 'utf8')
|
||||
// Content guards fail before the suite (cheap, PR-scoped, fail fast).
|
||||
const guard = pr.indexOf('name: Changelog is append-only')
|
||||
const lint = pr.indexOf('name: Lint')
|
||||
const e2e = pr.indexOf('name: E2E tests (full suite')
|
||||
expect(guard).toBeGreaterThan(-1)
|
||||
expect(lint).toBeGreaterThan(guard)
|
||||
expect(e2e).toBeGreaterThan(lint)
|
||||
// ci-pr never runs on the host (OPS53 pin).
|
||||
expect(pr).not.toContain('runs-on: host')
|
||||
// The skip wiring is the critical piece: losing the `scope` step id or
|
||||
// the e2e gate silently skips the suite with a green run (the opposite
|
||||
// of the fail-fast this delivery sells).
|
||||
expect(pr).toMatch(/id: scope\n/)
|
||||
expect(pr).toMatch(/if: steps\.scope\.outputs\.e2e_mode == 'full'/)
|
||||
|
||||
// main keeps the deploy gated on the checks job (OPS53) — anchored as a
|
||||
// YAML key so the header comment cannot satisfy the pin.
|
||||
const main = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8')
|
||||
expect(main).toMatch(/^ needs: \[checks\]\n/m)
|
||||
})
|
||||
|
||||
it('OPS62 X1: services are reached by name on the job network (no host ports)', () => {
|
||||
// Forgejo 9 does not expand expressions in `services.ports` (measured
|
||||
// live: run 730 failed 1s with the literal port string) and a fixed host
|
||||
// port would collide between concurrent runs — the single long-lived job
|
||||
// holds its services for the whole run. Pinned: DNS by name, no ports.
|
||||
for (const file of ['.forgejo/workflows/ci.yml', '.forgejo/workflows/ci-pr.yml']) {
|
||||
const source = readFileSync(join(repoRoot, file), 'utf8')
|
||||
expect(source, file).toContain('@postgres-int:5432/teqo_test')
|
||||
expect(source, file).toContain('@postgres-build:5432/teqo_test')
|
||||
expect(source, file).not.toMatch(/^ {6}ports:\n/m)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local e2e build artifacts outside the development dist directory', () => {
|
||||
for (const file of [
|
||||
'.forgejo/workflows/ci.yml',
|
||||
|
|
@ -146,6 +96,8 @@ describe('ciSkipInvariants', () => {
|
|||
for (const path of [
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/ci-classify-production.mjs',
|
||||
'scripts/lib/dockerignore.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/check-plans-only-pr-closes.mjs',
|
||||
'scripts/lib/plansOnlyClosesGuard.mjs',
|
||||
|
|
@ -159,6 +111,32 @@ describe('ciSkipInvariants', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('main CI runs in a 30-minute window with a production gate (OPS65)', () => {
|
||||
const ci = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8')
|
||||
expect(ci).toContain('schedule:')
|
||||
expect(ci).toContain("cron: '*/30 * * * *'")
|
||||
expect(ci).not.toMatch(/push:\s*\n\s*branches:\s*\[main\]/)
|
||||
expect(ci).toContain('workflow_dispatch:')
|
||||
|
||||
// The cheap gate (no pnpm) classifies production change and every
|
||||
// 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.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, gate]')
|
||||
expect(ci).toContain(
|
||||
"needs.checks.result == 'success' && needs.gate.outputs.production == 'true'",
|
||||
)
|
||||
})
|
||||
|
||||
it('every campaign domain dir is covered by the e2e affected manifest', () => {
|
||||
const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), {
|
||||
withFileTypes: true,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
|
|||
expect(script).toContain('stale run')
|
||||
})
|
||||
|
||||
it('skips idempotently when the running container already runs the SHA (OPS65)', () => {
|
||||
expect(script).toContain('already deployed')
|
||||
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', () => {
|
||||
expect(script).toContain('--secret')
|
||||
expect(script).toContain('id=database_url,env=DATABASE_URL')
|
||||
|
|
|
|||
107
tests/unit/dockerignoreMatch.unit.spec.ts
Normal file
107
tests/unit/dockerignoreMatch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
compileDockerignoreRule,
|
||||
isDockerignored,
|
||||
parseDockerignore,
|
||||
} from '../../scripts/lib/dockerignore.mjs'
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
||||
const dockerignore = readFileSync(join(repoRoot, '.dockerignore'), 'utf8')
|
||||
|
||||
describe('dockerignoreMatch (OPS65 production-change gate)', () => {
|
||||
it('compiles the moby semantics: basename at any depth, subtree exclusion', () => {
|
||||
const rules = parseDockerignore('docs\n*.log\n')
|
||||
expect(isDockerignored('docs', rules)).toBe(true)
|
||||
expect(isDockerignored('docs/plans/x.md', rules)).toBe(true)
|
||||
expect(isDockerignored('a/docs/plans/x.md', rules)).toBe(true)
|
||||
expect(isDockerignored('x.log', rules)).toBe(true)
|
||||
expect(isDockerignored('a/b/x.log', rules)).toBe(true)
|
||||
expect(isDockerignored('src/app/page.tsx', rules)).toBe(false)
|
||||
})
|
||||
|
||||
it('anchors leading-slash patterns to the context root', () => {
|
||||
const rules = parseDockerignore('/*.png\n')
|
||||
expect(isDockerignored('a.png', rules)).toBe(true)
|
||||
expect(isDockerignored('sub/b.png', rules)).toBe(false)
|
||||
})
|
||||
|
||||
it('honours ! negations with last-match-wins', () => {
|
||||
const rules = parseDockerignore('.env.*\n!.env.example\n')
|
||||
expect(isDockerignored('.env.local', rules)).toBe(true)
|
||||
expect(isDockerignored('.env.example', rules)).toBe(false)
|
||||
expect(isDockerignored('a/.env.example', rules)).toBe(false)
|
||||
})
|
||||
|
||||
it('supports globstar and directory-only patterns', () => {
|
||||
const rules = parseDockerignore('**/fixtures\na/**/b\n')
|
||||
expect(isDockerignored('x/fixtures/y.json', rules)).toBe(true)
|
||||
expect(isDockerignored('a/b', rules)).toBe(true)
|
||||
expect(isDockerignored('a/x/b', rules)).toBe(true)
|
||||
expect(isDockerignored('a/x/c/b', rules)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores blank lines and comments', () => {
|
||||
const rules = parseDockerignore('# header\n\ndocs\n')
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(isDockerignored('docs/x', rules)).toBe(true)
|
||||
})
|
||||
|
||||
it('escapes regex metacharacters in plain patterns', () => {
|
||||
const rules = parseDockerignore('foo.bar\n')
|
||||
expect(isDockerignored('foo.bar', rules)).toBe(true)
|
||||
expect(isDockerignored('fooXbar', rules)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats the repo .dockerignore as the source of truth', () => {
|
||||
const rules = parseDockerignore(dockerignore)
|
||||
// Anything the image needs is production: changes force the full suite.
|
||||
for (const path of [
|
||||
'src/app/page.tsx',
|
||||
'src/migrations/20260818_x.ts',
|
||||
'public/favicon.ico',
|
||||
'Dockerfile',
|
||||
'.dockerignore',
|
||||
'AGENTS.md',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'scripts/deploy-homeserver.sh',
|
||||
'scripts/ci-classify-production.mjs',
|
||||
'.forgejo/workflows/ci.yml',
|
||||
'.forgejo/workflows/ci-pr.yml',
|
||||
'tests/unit/dockerignoreMatch.unit.spec.ts',
|
||||
'.env.example',
|
||||
]) {
|
||||
expect(isDockerignored(path, rules), path).toBe(false)
|
||||
}
|
||||
// Non-artifact paths skip the suite (quiet days cost seconds).
|
||||
for (const path of [
|
||||
'docs/plans/x.md',
|
||||
'docs/ops/teqo-1313-deploy.md',
|
||||
'.agents/skills/foo/SKILL.md',
|
||||
'.env.local',
|
||||
'.env.test.local',
|
||||
'data/tse/x.zip',
|
||||
'media/x.jpg',
|
||||
'foo.png',
|
||||
'x.log',
|
||||
'x.tsbuildinfo',
|
||||
'node_modules/x',
|
||||
'.next/x',
|
||||
'.next-e2e/x',
|
||||
]) {
|
||||
expect(isDockerignored(path, rules), path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the .dockerignore itself explicit (self-referential gate)', () => {
|
||||
const rule = compileDockerignoreRule('!.dockerignore')
|
||||
expect(rule.negate).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue