From 9d7e4a14141b0eb6b1546608de02a974f2cc4ef0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 18 Aug 2026 20:06:01 -0300 Subject: [PATCH] ops(OPS65): CI do main em janela de 30 min (gate .dockerignore fail-open) + matar agent-pool - ci.yml: on push removido; schedule */30 + workflow_dispatch - job check (host, sem pnpm): SHA deployado via ssh + diff + classificador .dockerignore (scripts/ci-classify-production.mjs, lib/dockerignore.mjs) - suite fail-open no gate; deploy duplo-gated (checks + production) - deploy-homeserver.sh: guard 'already deployed' (idempotente) - agent-pool.yml removido; pool dormente - specs: dockerignoreMatch, ciClassifyProduction, pins ciSkipInvariants/deployScript - docs: runbook, AGENTS.md, AGENT-OPS.md, changelog --- .forgejo/workflows/agent-pool.yml | 69 ------- .forgejo/workflows/ci.yml | 66 ++++++- AGENTS.md | 2 +- docs/AGENT-OPS.md | 47 ++--- docs/CHANGELOG-AGENTS.md | 2 + docs/changelog/2026-08-18-ops65.md | 1 + docs/ops/teqo-1313-deploy.md | 41 ++-- ...-ci-main-janela-30min-e-matar-pool-impl.md | 183 ++++++++++++++++++ scripts/ci-classify-production.mjs | 131 +++++++++++++ scripts/deploy-homeserver.sh | 12 ++ scripts/lib/dockerignore.mjs | 106 ++++++++++ scripts/lib/test-affected-core.mjs | 2 + tests/unit/ciClassifyProduction.unit.spec.ts | 116 +++++++++++ tests/unit/ciSkipInvariants.unit.spec.ts | 26 +++ tests/unit/deployScript.unit.spec.ts | 6 + tests/unit/dockerignoreMatch.unit.spec.ts | 107 ++++++++++ 16 files changed, 806 insertions(+), 111 deletions(-) delete mode 100644 .forgejo/workflows/agent-pool.yml create mode 100644 docs/changelog/2026-08-18-ops65.md create mode 100644 docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md create mode 100644 scripts/ci-classify-production.mjs create mode 100644 scripts/lib/dockerignore.mjs create mode 100644 tests/unit/ciClassifyProduction.unit.spec.ts create mode 100644 tests/unit/dockerignoreMatch.unit.spec.ts diff --git a/.forgejo/workflows/agent-pool.yml b/.forgejo/workflows/agent-pool.yml deleted file mode 100644 index 4d5ed619..00000000 --- a/.forgejo/workflows/agent-pool.yml +++ /dev/null @@ -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[@]}" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 7027f2fa..b4a306e7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,4 +1,21 @@ -# Post-merge verifier on `main`: always the FULL suite (no affected skips). +# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the +# workflow no longer fires on every push. The `schedule` trigger polls every +# 30 min and a cheap gate (`check`, runs-on host, no pnpm) classifies whether +# anything that would enter the docker build context changed since the SHA +# running on the homeserver (read from `~/stack/docker-compose.yml` over ssh). +# - No production change → suite + deploy skip; the run stays green +# (the `checks` rollup already accepts skipped jobs). +# - Production change → full suite + deploy in the same window. +# - `workflow_dispatch` always runs the full pipeline (explicit operator +# intent); the deploy is idempotent via the "already deployed" guard in +# scripts/deploy-homeserver.sh. +# +# Gate fail-open: an unreadable deployed SHA or an ssh failure makes `check` +# report production=true, so the suite ALWAYS verifies main. The deploy job +# only runs when the gate succeeded AND production changed AND checks are +# green — never deploy blind. A gate failure self-heals on the next window +# (the diff is re-evaluated against the same deployed SHA). +# # When the suite is green, the `deploy` job publishes teqo-1313 on the # homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver # over SSH; the remote script builds the standalone image at the pushed SHA, @@ -17,8 +34,8 @@ name: CI on: - push: - branches: [main] + schedule: + - cron: '*/30 * * * *' workflow_dispatch: env: @@ -26,9 +43,37 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: + check: + name: check production change + # Host runner: needs `ssh homeserver` (same environment as the deploy + # job — the key lives on the workstation host) and a full clone to diff + # ..HEAD. Runs every window; the suite jobs below consume + # its `production` output. Deliberately wired ONLY here — main + dispatch + # — never in ci-pr.yml (host job = code execution as `fsolla`). + runs-on: host + timeout-minutes: 10 + outputs: + production: ${{ steps.classify.outputs.production }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Read deployed SHA and classify production change + id: classify + run: | + set -euo pipefail + deployed_sha="$(ssh homeserver "grep -oE 'image: teqo-1313:[0-9a-f]+' ~/stack/docker-compose.yml | sed -E 's/.*://' | head -1" 2>/dev/null || true)" + echo "deployed_sha: ${deployed_sha:-}" + node scripts/ci-classify-production.mjs --deployed "$deployed_sha" + echo "production=$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha" --value)" >> "$GITHUB_OUTPUT" + static: runs-on: ubuntu-latest timeout-minutes: 20 + needs: [check] + # Fail-open: an errored gate never skips the verifier; only a gate that + # succeeded AND found no production change skips the suite. + if: needs.check.result != 'success' || needs.check.outputs.production == 'true' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 @@ -48,6 +93,8 @@ jobs: int: runs-on: ubuntu-latest timeout-minutes: 25 + needs: [check] + if: needs.check.result != 'success' || needs.check.outputs.production == 'true' env: # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds # a Payload pool (max 10) → overflows the service Postgres' 100 clients @@ -92,6 +139,8 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 25 + needs: [check] + if: needs.check.result != 'success' || needs.check.outputs.production == 'true' services: postgres: image: postgres:17-alpine @@ -125,6 +174,8 @@ jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 40 + needs: [check] + if: needs.check.result != 'success' || needs.check.outputs.production == 'true' # Main always runs the full suite: split it across 2 jobs (OPS34), each # building its own `.next-e2e` (measured 2026-08-10: build 172s + tests # 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast: @@ -213,11 +264,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 rollup 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, check] + if: needs.checks.result == 'success' && needs.check.outputs.production == 'true' runs-on: host timeout-minutes: 60 steps: diff --git a/AGENTS.md b/AGENTS.md index e9250605..49692ca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: 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` verde == site atualizado em até 30 min. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 18432634..b0be0201 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -15,7 +15,7 @@ Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB` ```text claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main -main → ci.yml 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 em main == site atualizado em ≤30 min) ``` **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 -- ` | 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 -- ` | 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,14 +69,15 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | -| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks`; 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` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | +| `ci.yml` | schedule `*/30` / dispatch `main` | service Postgres | Janela fixa (OPS65): gate `check` (host, sem pnpm) classifica mudança de produção desde o SHA deployado (`.dockerignore` = fonte da verdade; fail-open); sem mudança → suite + deploy skipped, run verde; com mudança → full sempre + rollup `checks`; 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 | + +`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`. @@ -92,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 `check` (host, sem pnpm) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` etc. viram skip; `scripts/`, `tests/`, `.forgejo/` contam como produção — conservador). Sem mudança → suite e deploy skipped (run verde); gate com erro → suite roda (fail-open) e o deploy espera a próxima janela. Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente ("already deployed" → skip verde). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50. ## Cursor Cloud diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d14eb142..fc864fa4 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -53,6 +53,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 (`check`, runs-on `host`, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Sem mudança → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); com mudança → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (compose já referencia o SHA → skip verde). Fail-open: gate com erro → suite roda (main sempre verificada), deploy espera a próxima janela (auto-cura ≤30 min); deploy duplo-gated (`needs: [checks, check]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch), pins do `ciSkipInvariants` (janela 30, sem `push`, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/changelog/2026-08-18-ops65.md b/docs/changelog/2026-08-18-ops65.md new file mode 100644 index 00000000..6576b17d --- /dev/null +++ b/docs/changelog/2026-08-18-ops65.md @@ -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 (`check`, runs-on `host`, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê o SHA deployado do compose do homeserver via SSH, diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Sem mudança → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); com mudança → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (compose já referencia o SHA → skip verde). Fail-open: gate com erro → suite roda (main sempre verificada), deploy espera a próxima janela (auto-cura ≤30 min); deploy duplo-gated (`needs: [checks, check]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch), pins do `ciSkipInvariants` (janela 30, sem `push`, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 8b5cc0c5..ed5fbb68 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1,27 +1,40 @@ -# 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. +com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual, +em até 30 min após o merge. ## 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 `check` (gate barato, host, sem pnpm): lê o SHA deployado de + `~/stack/docker-compose.yml` no homeserver via `ssh` e classifica "mudança + de produção" com o `.dockerignore` como fonte da verdade (o que não entra + na imagem não justifica build novo; `docs/`, `.agents/`, `.env*` viram + skip; `scripts/`, `tests/`, `.forgejo/` contam como produção — + conservador, qualquer dúvida roda a suite). + - Sem mudança de produção → suite e deploy **skipped**; run verde. + - Gate com erro (ssh/SHA ilegível) → fail-open: suite roda; deploy fica + para a próxima janela (auto-cura em ≤30 min). +3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks). +4. Job `deploy` (runs-on `host`, na **workstation**) roda `ssh homeserver "bash -s -- " < scripts/deploy-homeserver.sh`. -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 + O script é **idempotente**: se o compose já referencia o SHA (ex.: dispatch + duplicado), sai verde "already deployed" sem rebuild. +5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job, + senão skip) → `flock` (serializa) → guard "already deployed" → build da + imagem standalone + migrator (BuildKit, secrets do `~/stack/teqo-1313.env`, + `--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default` + para o build alcançar o `postgres` — o proxy é criado idempotentemente pelo + script) → push em `localhost:5000` → swap dos tags de imagem no `~/stack/docker-compose.yml` (backup antes) → **migrations** (`docker compose --profile maintenance run --rm teqo-1313-migrate -rebuild`) e `docker compose up -d`, ou espere um commit novo | +| Gate `check` falha (ssh homeserver / SHA ilegível) | Tailnet fora ou compose ilegível | Fail-open: suite roda; deploy espera a próxima janela (auto-cura em ≤30 min se houver mudança de produção). Se persistir, checar `ssh homeserver` da workstation | | Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) | | Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy | | Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback | diff --git a/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md b/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md new file mode 100644 index 00000000..06c9e709 --- /dev/null +++ b/docs/plans/ops65-ci-main-janela-30min-e-matar-pool-impl.md @@ -0,0 +1,183 @@ +# 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
lê SHA deployado do compose no homeserver
git diff --name-only HEAD] + C --> G{produção mudou?
dockerignore do build context} + G -->|não| SK[suite + deploy skipped
run verde] + G -->|sim| SU[suite full] + SU --> CH[checks rollup] + CH --> D[deploy: já deployado?
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:`) → `git diff --name-only 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:** `ssh` falha / SHA deployado ilegível → `production = +true` (suite roda; o site continua verificado). No gate com erro, o deploy + **não** roda (nunca deployar no escuro) e a próxima janela (≤30 min) + auto-cura se houver mudança de produção. Condição dos jobs da suite: + `needs.check.result != 'success' || needs.check.outputs.production == 'true'`. +3. **Deploy condicionado duplo:** `needs: [checks, check]` + `if: +needs.checks.result == 'success' && needs.check.outputs.production == +'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 `, `--value`; `GITHUB_EVENT_NAME == workflow_dispatch` → + produção forçada). Sem pnpm, sem deps. +6. **Guard "already deployed" no `deploy-homeserver.sh`:** após o HEAD guard + + flock, antes de sourcear secrets: se o compose já referencia + `image: teqo-1313:$SHA` → `exit 0` ("nothing to do"). Mata o rebuild de ~15 + min em dispatch duplicado. Sem flag FORCE (YAGNI; força = fluxo manual do + runbook). +7. **Remoção do `on: push`:** branch protection do main exige apenas + `CI (PR) / checks*` (cascade do `ci-pr.yml`) — merges não dependem do + `ci.yml` pós-merge (verificado em `scripts/configure-branch-protection.mjs` + e `lib/branch-protection.mjs`). `agent-pr-ready-automerge` e + `issue-done-on-main-merge` são eventos de PR — intocados. +8. **`fetch-depth: 0` no job `check`** — o diff `..HEAD` precisa do + histórico; custo de 1 clone full a cada 30 min é desprezível vs a suite. + Overlap de janelas (suite > 30 min, sem `concurrency` no Forgejo): + limitado e auto-dedup via SHA deployado (a segunda janela vê deploy == HEAD + → skip); flock serializa deploy. Documentado como limite conhecido. + +### Componentes / mudanças + +- **`.forgejo/workflows/ci.yml`**: `on:` vira `schedule */30` + + `workflow_dispatch` (remove `push`); job `check` novo (host, 10 min, + outputs `production`/`deployed_sha`); `static/int/build/e2e` ganham `needs: +[check]` + `if` fail-open; `deploy` ganha `needs: [checks, check]` + `if` + duplo. Cabeçalho atualizado. +- **`.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 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. diff --git a/scripts/ci-classify-production.mjs b/scripts/ci-classify-production.mjs new file mode 100644 index 00000000..e9deba7e --- /dev/null +++ b/scripts/ci-classify-production.mjs @@ -0,0 +1,131 @@ +#!/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 FAILS CLOSED toward "production": dispatch (explicit + * operator intent), an unknown deployed SHA, an empty diff vs the deployed + * SHA being unreadable — any doubt runs the full suite. + * + * Plain Node, stdlib only — the gate must stay cheap (no pnpm install). + * + * node scripts/ci-classify-production.mjs --deployed [--value] + * node scripts/ci-classify-production.mjs --files-from [--value] + * + * Output: JSON { production, reason, changed, kept, skipped } on stdout + * (`--value` prints only `true`/`false`). Errors never fail the job: they + * log to stderr, report production=true and exit 0 — the suite runs. + */ +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)) +} + +const failOpen = (reason) => report(true, reason, { changed: [], kept: [], skipped: [] }) + +// 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) { + changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], { + encoding: 'utf8', + }) + .split('\n') + .filter(Boolean) + } else { + failOpen('sem SHA deployado nem files-from') + return + } + } catch (error) { + failOpen(`erro ao obter o diff (${error instanceof Error ? error.message : error})`) + return + } + + if (changed.length === 0) { + report(false, 'deployed_sha == HEAD (nada a fazer)', { + changed: [], + kept: [], + skipped: [], + }) + return + } + + let rules + try { + rules = parseDockerignore(readFileSync('.dockerignore', 'utf8')) + } catch (error) { + failOpen(`.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 }, + ) +} diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index 03122353..4fce8527 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -52,6 +52,18 @@ if [ "$main_head" != "$SHA" ]; then exit 0 fi +# --- idempotency (OPS65) ------------------------------------------------ +# The 30-min main window can re-deliver a SHA the compose already references +# (e.g. a duplicate workflow_dispatch): rebuilding it is a ~15 min no-op. +# The compose is the pipeline's source of truth for "deployed" — it is +# swapped atomically-ish before the rollout and restored on rollback. + +compose="$STACK_DIR/docker-compose.yml" +if [ -f "$compose" ] && grep -q "image: teqo-1313:$SHA" "$compose"; then + say "already deployed: compose references $SHA — nothing to do" + exit 0 +fi + # --- secrets (sourced locally, never echoed) ---------------------------- set -a diff --git a/scripts/lib/dockerignore.mjs b/scripts/lib/dockerignore.mjs new file mode 100644 index 00000000..c21a5dd3 --- /dev/null +++ b/scripts/lib/dockerignore.mjs @@ -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 +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index dc2491e2..eb2b2a7a 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/tests/unit/ciClassifyProduction.unit.spec.ts b/tests/unit/ciClassifyProduction.unit.spec.ts new file mode 100644 index 00000000..0d29b8ef --- /dev/null +++ b/tests/unit/ciClassifyProduction.unit.spec.ts @@ -0,0 +1,116 @@ +// @vitest-environment node + +import { execFileSync } 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 + +beforeAll(() => { + fixturesDir = mkdtempSync(join(tmpdir(), 'ci-classify-production-')) + filesFrom = join(fixturesDir, 'changed.txt') +}) + +afterAll(() => { + execFileSync('rm', ['-rf', fixturesDir]) +}) + +const runCli = (args: string[]) => { + const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' } + return execFileSync('node', [cliPath, ...args], { encoding: 'utf8', env }) +} + +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 result = JSON.parse(runCli(['--files-from', filesFrom])) + 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']).trim()).toBe('false') + writeChanged(['src/app/page.tsx']) + expect(runCli(['--files-from', filesFrom, '--value']).trim()).toBe('true') + }) + + it('an empty diff means nothing to do (production=false)', () => { + writeChanged([]) + const result = JSON.parse(runCli(['--files-from', filesFrom])) + expect(result.production).toBe(false) + expect(result.reason).toContain('nada a fazer') + }) + + it('fails open when the deployed SHA is unknown', () => { + const result = JSON.parse(runCli(['--deployed', ''])) + expect(result.production).toBe(true) + expect(result.reason).toContain('sem SHA deployado') + }) + + it('fails open without any input at all', () => { + const result = JSON.parse(runCli([])) + expect(result.production).toBe(true) + }) + + it('fails open on an unreadable .dockerignore', () => { + writeChanged(['src/app/page.tsx']) + const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' } + const out = execFileSync('node', [cliPath, '--files-from', filesFrom], { + encoding: 'utf8', + env, + cwd: fixturesDir, + }) + 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 out = execFileSync('node', [cliPath, '--files-from', filesFrom], { + encoding: 'utf8', + env, + }) + const result = JSON.parse(out) + expect(result.production).toBe(true) + expect(result.reason).toContain('workflow_dispatch') + }) +}) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index b23b7004..064b07d5 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -96,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', @@ -109,6 +111,30 @@ 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. + expect(ci).toContain('check production change') + expect(ci).toContain('fetch-depth: 0') + expect(ci).toContain('scripts/ci-classify-production.mjs') + expect(ci).toContain( + "needs.check.result != 'success' || needs.check.outputs.production == 'true'", + ) + + // Deploy is double-gated: green rollup AND production change (never + // deploy blind, never deploy with a red suite). + expect(ci).toContain('needs: [checks, check]') + expect(ci).toContain( + "needs.checks.result == 'success' && needs.check.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, diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 27b62774..9dca8aff 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -22,6 +22,12 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { expect(script).toContain('stale run') }) + it('skips idempotently when the compose already references the SHA (OPS65)', () => { + expect(script).toContain('already deployed') + expect(script).toContain('image: teqo-1313:$SHA') + expect(script).toContain('docker-compose.yml') + }) + it('builds with BuildKit secrets — prod credentials never enter image layers', () => { expect(script).toContain('--secret') expect(script).toContain('id=database_url,env=DATABASE_URL') diff --git a/tests/unit/dockerignoreMatch.unit.spec.ts b/tests/unit/dockerignoreMatch.unit.spec.ts new file mode 100644 index 00000000..82f99d0e --- /dev/null +++ b/tests/unit/dockerignoreMatch.unit.spec.ts @@ -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) + }) +})