1/**
2 * NutriFlow — Dagger CI/CD Pipeline
3 * Monorepo: apps/web (Next.js 14) + apps/api (FastAPI)
4 * Testeable en local: dagger call ci --source=.
5 */
6import { dag, Container, Directory, Secret, object, func } from "@dagger.io/dagger";
7
8@object()
9export class NutriflowCi {
10
11 // ─── ENTRYPOINT ────────────────────────────────────────────────
12 @func()
13 async ci(source: Directory): Promise<string> {
14 // Ejecutar web y api en paralelo
15 const [webResult, apiResult] = await Promise.all([
16 this.webCi(source),
17 this.apiCi(source),
18 ]);
19 // Integration tests con Postgres efímero
20 await this.integrationTest(source);
21 return `✅ CI passed | web: ${webResult} | api: ${apiResult}`;
22 }
23
24 // ─── FRONTEND (Next.js 14) ──────────────────────────────────────
25 @func()
26 webBase(source: Directory): Container {
27 return dag
28 .container()
29 .from("node:20-slim")
30 .withDirectory("/app", source.directory("apps/web"))
31 .withWorkdir("/app")
32 // Caché por capa: solo re-instala si package.json cambia
33 .withMountedCache("/app/node_modules", dag.cacheVolume("nutriflow-web-nm"))
34 .withMountedCache("/root/.npm", dag.cacheVolume("npm-global"))
35 .withExec(["npm", "ci", "--prefer-offline"]);
36 }
37
38 @func()
39 async webCi(source: Directory): Promise<string> {
40 await this.webBase(source)
41 .withExec(["npm", "run", "lint"])
42 .stdout();
43 await this.webBase(source)
44 .withExec(["npm", "run", "test", "--", "--run", "--coverage"])
45 .stdout();
46 await this.webBase(source)
47 .withExec(["npm", "run", "build"])
48 .stdout();
49 return "web ✅";
50 }
51
52 // ─── BACKEND (FastAPI / Python 3.12) ───────────────────────────
53 @func()
54 apiBase(source: Directory): Container {
55 return dag
56 .container()
57 .from("python:3.12-slim")
58 .withDirectory("/app", source.directory("apps/api"))
59 .withWorkdir("/app")
60 // Caché pip: solo re-descarga si requirements cambia
61 .withMountedCache("/root/.cache/pip", dag.cacheVolume("nutriflow-pip"))
62 .withExec(["pip", "install", "-r", "requirements.txt", "--no-deps"]);
63 }
64
65 @func()
66 async apiCi(source: Directory): Promise<string> {
67 await this.apiBase(source)
68 .withExec(["ruff", "check", "."])
69 .stdout();
70 await this.apiBase(source)
71 .withExec(["pytest", "tests/unit", "-q", "--tb=short"])
72 .stdout();
73 return "api ✅";
74 }
75
76 // ─── INTEGRATION TESTS (con Postgres efímero) ──────────────────
77 @func()
78 async integrationTest(source: Directory): Promise<string> {
79 const postgres = dag
80 .container()
81 .from("postgres:16")
82 .withEnvVariable("POSTGRES_PASSWORD", "test_pass")
83 .withEnvVariable("POSTGRES_DB", "nutriflow_test")
84 .withExposedPort(5432)
85 .asService();
86
87 return this.apiBase(source)
88 .withServiceBinding("db", postgres)
89 .withEnvVariable(
90 "DATABASE_URL",
91 "postgresql://postgres:test_pass@db:5432/nutriflow_test"
92 )
93 .withExec(["alembic", "upgrade", "head"])
94 .withExec(["pytest", "tests/integration", "-q"])
95 .stdout();
96 }
97
98 // ─── PUBLISH TO GHCR ───────────────────────────────────────────
99 @func()
100 async publish(
101 source: Directory,
102 ghcrToken: Secret, // Nunca en texto plano
103 tag: string = "latest",
104 ): Promise<string> {
105 // Build imagen API
106 const apiImage = dag
107 .container()
108 .from("python:3.12-slim")
109 .withDirectory("/app", source.directory("apps/api"))
109 .withWorkdir("/app")
110 .withExec(["pip", "install", "-r", "requirements.txt"])
111 .withEntrypoint(["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"])
112 .withRegistryAuth("ghcr.io", "nutriflow-bot", ghcrToken);
113
114 const ref = await apiImage.publish(
115 `ghcr.io/nutriflow/api:${tag}`
116 );
117 return `📦 ghcr.io/nutriflow/api:${tag} → ${ref}`;
118 }
119}
1$ dagger call ci --source=.
2✔ connect 0.2s
3✔ loading module 0.1s
4● NutriflowCi.ci(source: .)
5 ├─ ● webBase: node:20-slim ⚡ cached (0.0s)
6 ├─ ● webCi
7 │ ├─ withExec [npm run lint] 3.2s
8 │ ├─ withExec [npm run test] 23.1s
9 │ └─ withExec [npm run build] 18.7s → ⚡ cached next run
10 ├─ ● apiBase: python:3.12-slim ⚡ cached (0.0s)
11 ├─ ● apiCi
12 │ ├─ withExec [ruff check .] 2.1s
13 │ └─ withExec [pytest tests/unit] 31.4s
14 └─ ● integrationTest
15 ├─ service postgres:16 up 1.8s
16 ├─ withExec [alembic upgrade] 4.3s
17 └─ withExec [pytest tests/integration] 47.2s
18
19✅ CI passed | web: web ✅ | api: api ✅
20
21Total: 2m 14s (sin caché cold-start)
22Total: 0m 38s (con caché en runs subsiguientes)
name: CI
on: [push, pull_request]
jobs:
web-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: apps/web/package-lock.json
- run: cd apps/web && npm ci
- run: cd apps/web && npm run lint
web-test:
needs: web-lint
runs-on: ubuntu-latest
steps:
# ... 40 líneas más
api-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r apps/api/requirements.txt
- run: cd apps/api && ruff check .
api-test:
needs: api-lint
# ... 35 líneas
integration:
needs: [web-test, api-test]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test_pass
ports: [5432:5432]
options: --health-cmd pg_isready...
# ... 60 líneas más de setup
publish:
needs: integration
# ... 80 líneas de Docker build+push
# 320 líneas total — imposible testear en local
name: CI — NutriFlow
on:
push:
branches: [main, dev]
pull_request: {}
jobs:
ci:
runs-on: ubuntu-latest
permissions:
packages: write # para ghcr.io
steps:
- uses: actions/checkout@v4
- name: Run Dagger CI pipeline
uses: dagger/dagger-for-github@v7
with:
verb: call
args: ci --source=.
env:
DAGGER_CLOUD_TOKEN: ${{ secrets.DAGGER_CLOUD_TOKEN }}
- name: Publish on main
if: github.ref == 'refs/heads/main'
uses: dagger/dagger-for-github@v7
with:
verb: call
args: >-
publish
--source=.
--ghcr-token=env:GHCR_TOKEN
--tag=${{ github.sha }}
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 28 líneas — testeable en local con "dagger call"
Reproducible en local y CI
El mismo dagger call ci corre en tu MacBook y en ubuntu-latest de GitHub Actions. Sin "works on my machine".
Caché granular por contenedor
node_modules y pip solo se reinstalan cuando cambia package.json / requirements.txt. Cada paso es una capa cacheada independientemente.
Secretos seguros por diseño
El tipo Secret de Dagger nunca se serializa en capas de imagen ni en logs. GHCR_TOKEN pasa encriptado end-to-end.
Servicios efímeros
Postgres se levanta automáticamente para integration tests y se destruye al finalizar. Sin docker-compose, sin estado entre runs.
Funciones composables
Cada @func() es llamable de forma independiente. El dev puede correr solo dagger call api-ci mientras itera en el backend.
Dagger Cloud para equipos
Caché compartida entre todos los runners de CI. El segundo dev que hace push reutiliza la caché del primero. 0 configuración extra.
dagger/dagger-for-github@v7
contenedor aislado
node:20-slim
lint → test → build
python:3.12-slim
ruff → pytest unit
api + postgres:16 efímero
alembic + pytest integration
ghcr.io/nutriflow/api:<sha>
dagger call ci --source=. en tu laptop