# CI Soberano — Agent Squad

> Documentación de la estructura del CI/CD self-hosted (Forgejo + runners propios + deploy a Vercel).
> **Sin valores sensibles**: las claves, tokens y secretos NO se transcriben; solo se referencia **dónde viven** (rutas de archivo / nombre del secret).

---

## 1. Qué es y por qué

Agent Squad migró su git + CI de **GitHub Actions** a **Forgejo self-hosted** para eliminar la dependencia de GitHub.

- **Gatillo**: el billing de GitHub Actions agotado bloquea Actions a **nivel cuenta** (hosted *y* self-hosted: el runner sano recibía 0 jobs). Se eligió soberanía en vez de subir el límite de gasto.
- **Decisión**: Forgejo (no Gitea) por gobernanza nonprofit + GPL.
- **Alcance**: código + CI **soberanos**; **Vercel sigue hosteando** la web. El deploy pasó de `gitSource: github` a `vercel deploy --prebuilt` disparado desde el CI.
- **Reversible**: Forgejo corrió en paralelo a GitHub hasta el cutover; GitHub quedó fuera del path de deploy.

---

## 2. Infraestructura

### Servidor de Forgejo — VPS "truo"
- Ubuntu 26.04 KVM, 4 vCPU / 8 GB. Proveedor truo.co.
- **SSH**: password login DESACTIVADO; solo clave dedicada (ver §6). `ufw` solo 22 entrante.
- **Forgejo v16.0.3** en Docker (`/opt/forgejo`, base **SQLite**, `docker run` sin compose).
- **Público en `https://git.digitalhubassist.ai`** vía **Cloudflare Tunnel** (`cloudflared` systemd): sin puertos abiertos, **IP del servidor oculta**, satisface Cloudflare Full (Strict).
- **act_runner** = `forgejo-runner` (systemd, `User=runner`, modo mixto host/docker).

### Servidor secundario — box de runners (Hetzner)
- Hetzner 8 vCPU / 16 GB. Corre un **segundo runner** en `~/forgejo-runner-hetzner/` (systemd `forgejo-runner-hetzner.service`, `User=clawd`).
- Es también el host de operación (desde acá se orquesta y monitorea el CI).

> Las IPs de ambos VPS se omiten a propósito: el Cloudflare Tunnel oculta la del box de Forgejo, y exponerlas en una página pública facilitaría reconocimiento.

---

## 3. Runners (paralelización)

| Runner | ID | Labels | Rol |
|--------|----|--------|-----|
| **truo** | 4 | `self-hosted,linux,x64,pw` | Jobs host-mode (node/bun/ffmpeg/psql del host) + shards Playwright docker. **Único runner del job `deploy`** (`runs-on: [self-hosted,linux,x64]`). |
| **Hetzner** | 5 | `pw` | Solo shards Playwright en docker-mode. |

- **Paralelización**: los 6 shards de Playwright (fast 1-2 + 3D 1-4) se reparten entre truo y Hetzner → la suite baja de ~35 min a ~18 min.
- **Modo mixto por label**: la mayoría de jobs corren en **host-mode**; los jobs Playwright usan el label `pw` → **docker-mode** con imagen custom `local/playwright-bun:v1.60.0` (= `mcr.microsoft.com/playwright:v1.60.0-noble` + `unzip`), porque Playwright 1.60 no soporta chromium en el Ubuntu 26.04 del host.
- **⚠️ La imagen `local/playwright-bun:v1.60.0` NO debe borrarse** con `docker image prune -a` (rompe los shards `pw` de Hetzner). Se reconstruye con `docker build -f ~/forgejo-runner-hetzner/Dockerfile.pw -t local/playwright-bun:v1.60.0 .`

---

## 4. Estructura del workflow — `.github/workflows/ci.yml`

### Triggers (`on:`)
- `push` → `branches: [main]`
- `pull_request` → `branches: [main]`
- `schedule` (nightly ~6 UTC — red de seguridad del filtrado por paths)
- `workflow_dispatch` (manual)

### Job de gating: `cambios` ("Qué tocó este push")
Usa un filtro de paths que produce outputs booleanos que gatean el resto de los jobs:

| Output | Se activa si el diff toca… |
|--------|---------------------------|
| `web` | `^apps/web/` |
| `web3d` | `apps/web/src/lib/`, `.../office/`, `static/`, configs de playwright/vite/svelte, `^packages/`, etc. (glob amplio de la escena 3D) |
| `api` | `^apps/api/` o `^db/` |
| `compositor` | `^apps/api/scripts/compose169/` |
| `runtime` | shards de Playwright |

`INOCUOS` (no corren shards): `docs/`, `_design/`, `experiments/`, `substrate-infra/`, `*.md`, **`apps/api/`**, etc.

### Jobs y dependencias (DAG)

| Job | `runs-on` | `needs` | Gate (`if`) |
|-----|-----------|---------|-------------|
| `cambios` | host | — | siempre |
| `check` (Type-check + deprecated scan) | host | — | siempre |
| `web-test` (vitest web) | host | check, cambios | `cambios.web == true` |
| `api-test` (vitest + Postgres) | host | check, cambios | `cambios.api == true` |
| `compose169-test` (ffmpeg) | host | cambios | `cambios.compositor == true` |
| `e2e-criticos` (bloquean el deploy) | `pw` (docker) | — | **corre siempre** (gate barato ~22s, sin shards) |
| `e2e-fast` (Playwright fast 1-2) | `pw` (docker) | check, cambios | push/PR con `cambios.web == true` |
| `e2e-3d` (Playwright 3D 1-4) | `pw` (docker) | check, cambios | push/PR con `cambios.web3d == true` |
| `deploy` (Deploy a producción) | host (**solo truo**) | check, cambios, web-test, api-test, e2e-criticos, e2e-fast, e2e-3d | ver §5 |

- **Postgres de test**: contenedor persistente `substrate-test-pg` (timescaledb-ha:pg16) en `127.0.0.1:5432` (NO `localhost` → resuelve a ::1). El paso de migraciones hace `dropdb/createdb` por corrida.
- **cache OFF** (`cache.enabled: false`): el cache server no es alcanzable desde el contenedor y colgaba ~4.5 min por corrida.
- **Actions de GitHub** (`uses: actions/*`) se resuelven desde github.com (`DEFAULT_ACTIONS_URL=github`).

---

## 5. Pipeline de deploy (Vercel `--prebuilt`)

El job `deploy` corre en **todo push a main que pase el gate** (no está gateado por `cambios.web`):

```
if: always() && ref==refs/heads/main && event=='push'
    && cambios==success && check==success && e2e-criticos==success
    && (web-test  == success || skipped)
    && (api-test  == success || skipped)
    && (e2e-fast  == success || skipped)
    && (e2e-3d    == success || skipped)
```

> `always()` es necesario porque Forgejo **skipea** un job si un `need` está skipped (a diferencia de GitHub); el gate exige explícitamente los estados válidos.

### Pasos del deploy (escalonado, con red de seguridad)
1. **Build + deploy staged**: `vercel pull` → `vercel build --prod` → `vercel deploy --prebuilt --prod --skip-domain --env APP_COMMIT_SHA=$SHA`. `--skip-domain` deja el dominio custom **intacto** (sube un deployment de prod sin promoverlo).
2. **Smoke sobre la URL inmutable** (pre-promote): valida `commit` en `/api/version` + rutas `/`, `/demo`, `/welcome`. Un rojo acá **no toca producción**.
3. **Freshness + promote**: promueve (mueve el alias del dominio) **solo si** el `$SHA` es HEAD de main **o ancestro de HEAD** (`git merge-base --is-ancestor`). Evita el livelock de ráfaga (un push viejo no revierte producción en silencio).
4. **Smoke contra el dominio + rollback** si falla.

### Detalles de operación del CLI
- CLI pinneado a `bunx vercel@59.5.0`. Correr **desde la raíz del repo** (rootDirectory = `apps/web`).
- `vc <timeout> <retry|noretry> …`: wrapper con `timeout --signal=KILL` + reintento acotado (solo ante timeout 124/137). `< /dev/null` para evitar `SIGTTIN` (el CLI se detenía leyendo stdin en background).
- **`APP_COMMIT_SHA`** se inyecta por `--env` y `/api/version` lo lee en runtime (`VERCEL_*` es prefijo reservado).

### Consecuencias verificadas
- **La web (`/api/version`) sigue el HEAD de cada push a main**, toque o no `apps/web/` (el promote no gatea por web). Un commit API-only redespliega la web con el nuevo `APP_COMMIT_SHA`.
- La web queda "detrás" del HEAD **transitoriamente** cuando: (a) el `deploy` corre solo en truo y encola detrás de los shards durante ráfagas de merges; (b) el `deploy` espera a que termine `api-test` (suite lenta). Ninguno es atasco.

---

## 6. Dónde viven las variables, tokens y secretos

> **Solo referencias de ubicación** — ningún valor se transcribe. Para leer un valor, abrir el archivo en el host correspondiente.

### En el box de operación (Hetzner)
| Qué | Ubicación | Notas |
|-----|-----------|-------|
| Token Forgejo **read** | `~/.agent-squad-forgejo-token` | 40 hex, sin scope admin. Para monitoreo/API. |
| Token Forgejo **write** (merge/deploy del peer) | `~/.agent-squad-forgejo-write-token` | `write:repository`, modo 600. Revocable. |
| `VERCEL_TOKEN` + org/project IDs | `~/.env` | `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`. |
| Credenciales del repo LPDI/otros | `.env` del repo respectivo | **NUNCA** mezclar con `~/.env`. |
| Clave SSH a truo | `~/.ssh/truo_runner` (+ alias `truo` en `~/.ssh/config`) | La clave por defecto `id_ed25519` NO está autorizada en truo. |

### En el servidor de Forgejo (truo)
| Qué | Ubicación | Notas |
|-----|-----------|-------|
| Credencial admin de Forgejo | `/opt/forgejo/.admin-cred` | Usuario admin `aguirrerjg`. |
| Base de datos de Forgejo | `/opt/forgejo/data/forgejo/forgejo.db` | SQLite; leer/editar con `python3` del host (backup con `sqlite3 … ".backup"` antes de tocar). |
| Config del runner | `~/forgejo-runner/config.yaml` (User=runner) | Labels se registran en `.runner`, no en config.yaml. |
| Auth de Cloudflare (tunnel/DNS) | Global API Key de Cloudflare | El token scoped estaba inválido; se usa la Global API Key. |

### Secrets del CI (definidos en Forgejo, repo `aguirrerjg/agent-squad-app`)
Se referencian en `ci.yml` como `${{ secrets.* }}` — **no se transcriben**:
- `VERCEL_TOKEN`
- `VERCEL_PROJECT_ID`
- `VERCEL_TEAM_ID` (== org id)

---

## 7. Operación y monitoreo

### Acceso
- **truo**: `ssh truo` (alias en `~/.ssh/config` → clave `truo_runner`, user root).
- **Forgejo API**: `https://git.digitalhubassist.ai/api/v1/repos/aguirrerjg/agent-squad-app/...` con `Authorization: token <read-token>`. Usar **curl** (urllib da 403 por Cloudflare UA).

### Endpoints útiles
- `GET .../actions/tasks?limit=N` — estado de jobs (running/success/…).
- `GET .../branches/main` — HEAD de main.
- `GET .../actions/runs/{index}/logs` — zip de logs (pero ver gotcha ↓).
- `POST .../actions/runs/{index}/cancel` → 204 — cancela un run (usa el **index** = run_number). **Rerun NO existe por API** (404): relanzar es desde la UI web o un nuevo push.
- Estado de la web servida: `GET https://app.agentsquadai.com/api/version` → `{ commit }`.

### `status` en la DB (action_run / action_run_job / action_task)
**Enum (Forgejo `models/actions/status.go`):**

| # | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| estado | unknown | **success** | **failure** | **cancelled** | **skipped** | **waiting** | **running** | **blocked** |

> ⚠️ No es `{1:waiting,…}`. Cruzar siempre con la API REST (que da strings) antes de actuar sobre la DB.

### Logs reales del runner (la API sirve logs stale)
`/opt/forgejo/data/gitea/actions_log/<owner>/<repo>/<xx>/<taskid>.log.zst` (zstd; `zstd -dc`). El `log_filename` sale de `SELECT log_filename FROM action_task WHERE id=<taskid>`. Marcadores del act: `⭐ Run <step>`, `🏁 Job failed`, `⚙️ RUN exit status N` (124=timeout TERM, 137=SIGKILL).

---

## 8. Gotchas y lecciones

- **Auto-deploy exige CI verde de `origin/main`**: el gate del `auto-deploy-api.sh` chequea `GET .../commits/{sha}/status`; skipped=verde, unknown→BLOCKED. Fail-closed.
- **Contención de Postgres (pgvectorscale/TimescaleDB)**: `substrate-test-pg` NO tolera construir una 2ª base en el mismo cluster (el worker de TS por base cuelga `CREATE DATABASE … TEMPLATE`). Correr la mordida RLS contra la 1ª base, no crear una 2ª.
- **`SIGTTIN`**: un proceso que lee stdin sin TTY se detiene (state `T`), y `timeout` también → usar `< /dev/null`.
- **Livelock de deploy (resuelto)**: con `HEAD == SHA` cada deploy en ráfaga salía verde sin promover; se arregló promoviendo por **ancestría**.
- **El deploy corre solo en truo** (`runs-on: self-hosted`): durante ráfagas de PRs, los deploys de main encolan detrás de los shards.
- **Rerun no existe por API**; un "reset" manual por DB no relanza (Forgejo tiene su propia máquina de estados) — solo sirve para dejar runs en terminal limpio.

---

*Documento generado desde el host de operación. Contenido sensible referenciado por ubicación, nunca transcrito.*
