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: githubavercel deploy --prebuiltdisparado 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).
ufwsolo 22 entrante. - Forgejo v16.0.3 en Docker (
/opt/forgejo, base SQLite,docker runsin compose). - Público en
https://git.digitalhubassist.aivía Cloudflare Tunnel (cloudflaredsystemd): 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/(systemdforgejo-runner-hetzner.service,User=clawd). - Es también el host de operación: desde acá se orquesta y monitorea el CI.
3 Runners y 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 customlocal/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.
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:)
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»)
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 + scan) | host | — | siempre |
web-test (vitest web) | host | check, cambios | cambios.web |
api-test (vitest + Postgres) | host | check, cambios | cambios.api |
compose169-test (ffmpeg) | host | cambios | cambios.compositor |
e2e-criticos (bloquean el deploy) | pw docker | — | corre siempre (~22s, sin shards) |
e2e-fast (PW fast 1-2) | pw docker | check, cambios | push/PR con cambios.web |
e2e-3d (PW 3D 1-4) | pw docker | check, cambios | push/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) en127.0.0.1:5432(NOlocalhost→ resuelve a ::1). Migraciones hacendropdb/createdbpor 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)
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 pull→vercel build --prod→vercel deploy --prebuilt --prod --skip-domain --env APP_COMMIT_SHA=$SHA.--skip-domaindeja el dominio custom intacto (sube un deployment de prod sin promoverlo). - 2. Smoke sobre la URL inmutable (pre-promote): valida
commiten/api/version+ rutas/,/demo,/welcome. Un rojo acá no toca producción. - 3. Freshness + promote: promueve (mueve el alias del dominio) solo si el
$SHAes 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 contimeout --signal=KILL+ reintento acotado (solo ante timeout 124/137).< /dev/nullpara evitarSIGTTIN.APP_COMMIT_SHAse inyecta por--envy/api/versionlo lee en runtime (VERCEL_*es prefijo reservado).
/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)
write:repository, modo 600) — merge/promote del peer. Revocable.VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID.~/.env.id_ed25519 NO está autorizada ahí.En el servidor de Forgejo (truo)
aguirrerjg).python3 del host; backup con sqlite3 … ".backup" antes de tocar..runner, no en config.yaml.Secrets del CI (definidos en Forgejo, repo aguirrerjg/agent-squad-app)
Referenciados en ci.yml como ${{ secrets.* }} — no se transcriben:
VERCEL_TOKENVERCEL_PROJECT_IDVERCEL_TEAM_ID(== org id)
7 Operación y monitoreo
Acceso
- truo:
ssh truo(alias en~/.ssh/config→ clavetruo_runner, user root). - Forgejo API:
https://git.digitalhubassist.ai/api/v1/repos/aguirrerjg/agent-squad-app/…conAuthorization: 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)
| # | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| estado | unknown | success | failure | cancelled | skipped | waiting | running | blocked |
{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 delauto-deploy-api.shchequeaGET …/commits/{sha}/status; skipped=verde, unknown→BLOCKED. Fail-closed. - Contención de Postgres (pgvectorscale/TimescaleDB):
substrate-test-pgNO tolera construir una 2ª base en el mismo cluster (el worker de TS por base cuelgaCREATE DATABASE … TEMPLATE). Correr la mordida RLS contra la 1ª base. SIGTTIN: un proceso que lee stdin sin TTY se detiene (stateT), ytimeouttambién → usar< /dev/null.- Livelock de deploy (resuelto): con
HEAD == SHAcada 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.