Agent Squad · Infraestructura

CI Soberano — Forgejo, runners propios y deploy a Vercel

Estructura completa del pipeline self-hosted que reemplazó a GitHub Actions: cómo está armado el CI, quién corre qué, cómo despliega a producción, y dónde viven las credenciales.

Forgejo v16.0.3 2 runners · truo + Hetzner Deploy: Vercel --prebuilt git.digitalhubassist.ai app.agentsquadai.com
🔒 Sin datos sensibles. Este documento NO transcribe tokens, claves ni secretos: solo referencia dónde viven (ruta de archivo / nombre del secret). Las IPs de los VPS se omiten a propósito (el Cloudflare Tunnel oculta la del box de Forgejo).

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

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.
IPs omitidas a propósito. El Cloudflare Tunnel oculta la IP del box de Forgejo; exponerlas en una página pública facilitaría reconocimiento.

3 Runners y paralelización

RunnerIDLabelsRol
truo4 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]).
Hetzner5 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 pwdocker-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.
⚠️ No borrar la imagen local/playwright-bun:v1.60.0 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 Workflow · .github/workflows/ci.yml

Triggers (on:)

  • pushbranches: [main]
  • pull_requestbranches: [main]
  • schedule (nightly ~6 UTC — red de seguridad del filtrado por paths)
  • workflow_dispatch (manual)

Job de gating: cambios («Qué tocó este push»)

Filtro de paths que produce outputs booleanos que gatean el resto de los jobs:

OutputSe activa si el diff toca…
web^apps/web/
web3dapps/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/
runtimeshards de Playwright

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

Jobs y dependencias (DAG)

Jobruns-onneedsGate (if)
cambioshostsiempre
check (Type-check + scan)hostsiempre
web-test (vitest web)hostcheck, cambioscambios.web
api-test (vitest + Postgres)hostcheck, cambioscambios.api
compose169-test (ffmpeg)hostcambioscambios.compositor
e2e-criticos (bloquean el deploy)pw dockercorre siempre (~22s, sin shards)
e2e-fast (PW fast 1-2)pw dockercheck, cambiospush/PR con cambios.web
e2e-3d (PW 3D 1-4)pw dockercheck, cambiospush/PR con cambios.web3d
deploy (Deploy a producción)host (solo truo)todos ↑ver §5
  • Postgres de test: contenedor persistente substrate-test-pg (timescaledb-ha:pg16) en 127.0.0.1:5432 (NO localhost → resuelve a ::1). Migraciones hacen dropdb/createdb por corrida.
  • cache OFF (cache.enabled: false): el cache server no era alcanzable desde el contenedor y colgaba ~4.5 min/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)
Por qué always(): Forgejo skipea un job si un need está skipped (a diferencia de GitHub); el gate exige explícitamente los estados válidos para forzar que el deploy corra igual.

Pasos del deploy (escalonado, con red de seguridad)

  • 1. Build + deploy staged: vercel pullvercel build --prodvercel 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.
  • 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.
  • APP_COMMIT_SHA se inyecta por --env y /api/version lo lee en runtime (VERCEL_* es prefijo reservado).
Consecuencia verificada: la web (/api/version) sigue el HEAD de cada push a main, toque o no apps/web/ (el promote no gatea por web). La web queda "detrás" del HEAD transitoriamente cuando el deploy (solo en truo) encola detrás de shards, o espera a api-test. 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)

~/.agent-squad-forgejo-tokenToken Forgejo read (40 hex, sin scope admin) — monitoreo/API.
~/.agent-squad-forgejo-write-tokenToken Forgejo write (write:repository, modo 600) — merge/promote del peer. Revocable.
~/.envVERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID.
<repo>/.envCredenciales del repo respectivo (LPDI, etc.). NUNCA mezclar con ~/.env.
~/.ssh/truo_runner  (+ alias «truo» en ~/.ssh/config)Clave SSH a truo. La clave por defecto id_ed25519 NO está autorizada ahí.

En el servidor de Forgejo (truo)

/opt/forgejo/.admin-credCredencial admin de Forgejo (usuario aguirrerjg).
/opt/forgejo/data/forgejo/forgejo.dbBase SQLite. Leer/editar con python3 del host; backup con sqlite3 … ".backup" antes de tocar.
~/forgejo-runner/config.yaml  (User=runner)Config del runner. Labels se registran en .runner, no en config.yaml.
Cloudflare · Global API KeyAuth del tunnel/DNS (el token scoped estaba inválido; se usa la Global API Key).

Secrets del CI (definidos en Forgejo, repo aguirrerjg/agent-squad-app)

Referenciados 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.
  • GET …/branches/main — HEAD de main.
  • 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.
  • Web servida: GET https://app.agentsquadai.com/api/version{ commit }.

status en la DB (action_run / _job / _task)

#01234567
estadounknownsuccessfailurecancelledskippedwaitingrunningblocked
⚠️ No es {1:waiting,…}. Enum de Forgejo models/actions/status.go (iota desde success=1). 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: ⭐ Run, 🏁 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.
  • 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.