⚙️

NutriFlow — CI/CD con Dagger

Monorepo Next.js + FastAPI · Pipelines como código TypeScript · 100% reproducible en local

Dagger v0.12
TypeScript SDK
GitHub Actions
📄 dagger/src/index.ts
🔄 .github/workflows/ci.yml
⚡ Rendimiento y caché
🏗️ Arquitectura
Tiempo total CI
2:14min
↓ 74% vs YAML anterior (8:42 min)
Líneas de código
187líneas TS
← Era 320 líneas YAML ❌
Cache hit rate
94%
node_modules + pip + Docker layers
dagger/src/index.ts
TypeScript
  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}
Ejecución local
Terminal — MacBook Pro del dev
 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)
Antes 320 líneas YAML
.github/workflows/ci-old.yml
YAML
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
Ahora 28 líneas YAML
.github/workflows/ci.yml
YAML
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"
Comparativa cold-start vs caché
PRIMER RUN (cold, sin caché)
web:node_modules install
⬇ descarga npm 187 pkgs — 42s
42s
web:lint+test+build
lint 8s + test 23s + build 19s
50s
api:pip install
⬇ descarga pip 31 pkgs — 25s
25s
api:lint+test
ruff 2s + pytest unit 31s
33s
integration (Postgres)
pg start + migrate + pytest 47s
53s
Total cold: 3m 23s
RUN CON CACHÉ (solo cambió un archivo .py)
web:node_modules install
⚡ cached
0.1s
web:lint+test+build
⚡ cached (no changes)
0.2s
api:pip install
⚡ cached
0.1s
api:lint+test
ruff 2s + pytest unit 31s (archivo cambió)
33s
integration (Postgres)
re-ejecuta por cambio en api
47s
Total con caché: 1m 20s ↓ 61%
🔄

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.

Flujo del pipeline NutriFlow
git push / PR
GitHub Actions
dagger/dagger-for-github@v7
↓ llama
Dagger Engine
contenedor aislado
↓ ejecuta en paralelo
webCi()
node:20-slim
lint → test → build
apiCi()
python:3.12-slim
ruff → pytest unit
↓ ambos OK
integrationTest()
api + postgres:16 efímero
alembic + pytest integration
↓ solo en main
publish()
ghcr.io/nutriflow/api:<sha>
Idéntico si corres dagger call ci --source=. en tu laptop