Files
Hector Ros db71705842 Complete documentation for future sessions
- CLAUDE.md for AI agents to understand the codebase
- GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth)
- DEVELOPMENT-WORKFLOW.md explains complete dev process
- ROADMAP.md, NEXT-SESSION.md for planning
- QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use
- 40+ detailed docs in /docs folder
- Backend as submodule from Gitea

Everything documented for autonomous operation.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 00:37:19 +01:00

12 KiB

CI/CD Pipeline

Arquitectura CI/CD

Git Push → Gitea Webhook → Backend → BullMQ → Deploy Worker → K8s
                                         ↓
                                    Notifications

Gitea Actions (GitHub Actions compatible)

Workflow para Backend

# .gitea/workflows/backend.yml
name: Backend CI/CD

on:
  push:
    branches: [main, develop, staging]
    paths:
      - 'backend/**'
  pull_request:
    branches: [main, develop]
    paths:
      - 'backend/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.6

      - name: Install dependencies
        working-directory: ./backend
        run: bun install

      - name: Run linter
        working-directory: ./backend
        run: bun run lint

      - name: Run tests
        working-directory: ./backend
        run: bun test

  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.DOCKER_REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          push: true
          tags: |
            ${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:${{ github.sha }}
            ${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:latest
          cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:buildcache
          cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:buildcache,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Trigger deployment
        run: |
          curl -X POST ${{ secrets.AIWORKER_API_URL }}/api/deployments \
            -H "Authorization: Bearer ${{ secrets.AIWORKER_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{
              "projectId": "backend",
              "environment": "${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}",
              "commitHash": "${{ github.sha }}",
              "branch": "${{ github.ref_name }}"
            }'

Workflow para Frontend

# .gitea/workflows/frontend.yml
name: Frontend CI/CD

on:
  push:
    branches: [main, staging]
    paths:
      - 'frontend/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.6

      - name: Install and build
        working-directory: ./frontend
        run: |
          bun install
          bun run build

      - name: Build Docker image
        run: |
          docker build -t aiworker-frontend:${{ github.sha }} ./frontend
          docker tag aiworker-frontend:${{ github.sha }} aiworker-frontend:latest

      - name: Push to registry
        run: |
          echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
          docker push aiworker-frontend:${{ github.sha }}
          docker push aiworker-frontend:latest

      - name: Deploy
        run: |
          kubectl set image deployment/frontend frontend=aiworker-frontend:${{ github.sha }} -n control-plane

Webhooks Handler

// services/gitea/webhooks.ts
export async function handlePushEvent(payload: any) {
  const { ref, commits, repository } = payload
  const branch = ref.replace('refs/heads/', '')

  logger.info(`Push to ${repository.full_name}:${branch}`, {
    commits: commits.length,
  })

  // Find project by repo
  const project = await db.query.projects.findFirst({
    where: eq(projects.giteaRepoUrl, repository.clone_url),
  })

  if (!project) {
    logger.warn('Project not found for repo:', repository.clone_url)
    return
  }

  // Determine environment based on branch
  let environment: 'dev' | 'staging' | 'production' | null = null

  if (branch === 'main' || branch === 'master') {
    environment = 'production'
  } else if (branch === 'staging') {
    environment = 'staging'
  } else if (branch === 'develop' || branch === 'dev') {
    environment = 'dev'
  }

  if (!environment) {
    logger.debug('Ignoring push to non-deployment branch:', branch)
    return
  }

  // Create deployment
  const deploymentId = crypto.randomUUID()
  const commitHash = commits[commits.length - 1].id

  await db.insert(deployments).values({
    id: deploymentId,
    projectId: project.id,
    environment,
    deploymentType: 'automatic',
    branch,
    commitHash,
    status: 'pending',
  })

  // Enqueue deployment job
  await enqueueDeploy({
    deploymentId,
    projectId: project.id,
    environment,
    branch,
    commitHash,
  })

  logger.info(`Deployment queued: ${environment} for ${project.name}`)
}

Manual Deployment

// api/routes/deployments.ts
router.post('/deployments', async (req, res) => {
  const { projectId, environment, commitHash, branch } = req.body

  // Validate
  const project = await db.query.projects.findFirst({
    where: eq(projects.id, projectId),
  })

  if (!project) {
    return res.status(404).json({ error: 'Project not found' })
  }

  // Create deployment record
  const deploymentId = crypto.randomUUID()

  await db.insert(deployments).values({
    id: deploymentId,
    projectId,
    environment,
    deploymentType: 'manual',
    branch,
    commitHash,
    status: 'pending',
    triggeredBy: req.user?.id,
  })

  // Enqueue
  await enqueueDeploy({
    deploymentId,
    projectId,
    environment,
    branch,
    commitHash,
  })

  res.status(201).json({
    deploymentId,
    status: 'pending',
  })
})

Deployment Worker

// services/queue/deploy-worker.ts
import { Worker } from 'bullmq'
import { K8sClient } from '../kubernetes/client'
import { db } from '../../db/client'
import { deployments } from '../../db/schema'
import { eq } from 'drizzle-orm'

const k8sClient = new K8sClient()

export const deployWorker = new Worker(
  'deploys',
  async (job) => {
    const { deploymentId, projectId, environment, branch, commitHash } = job.data

    logger.info(`Starting deployment: ${environment}`, { deploymentId })

    // Update status
    await db.update(deployments)
      .set({
        status: 'in_progress',
        startedAt: new Date(),
      })
      .where(eq(deployments.id, deploymentId))

    job.updateProgress(10)

    try {
      // Get project config
      const project = await db.query.projects.findFirst({
        where: eq(projects.id, projectId),
      })

      if (!project) {
        throw new Error('Project not found')
      }

      job.updateProgress(20)

      // Build image tag
      const imageTag = `${project.dockerImage}:${commitHash.slice(0, 7)}`

      // Determine namespace
      const namespace =
        environment === 'production'
          ? `${project.k8sNamespace}-prod`
          : environment === 'staging'
          ? `${project.k8sNamespace}-staging`
          : `${project.k8sNamespace}-dev`

      job.updateProgress(30)

      // Create/update deployment
      await k8sClient.createOrUpdateDeployment({
        namespace,
        name: `${project.name}-${environment}`,
        image: imageTag,
        envVars: project.envVars as Record<string, string>,
        replicas: environment === 'production' ? project.replicas : 1,
        resources: {
          cpu: project.cpuLimit || '500m',
          memory: project.memoryLimit || '512Mi',
        },
      })

      job.updateProgress(70)

      // Create/update service
      await k8sClient.createOrUpdateService({
        namespace,
        name: `${project.name}-${environment}`,
        port: 3000,
      })

      job.updateProgress(80)

      // Create/update ingress
      const host =
        environment === 'production'
          ? `${project.name}.aiworker.dev`
          : `${environment}-${project.name}.aiworker.dev`

      const url = await k8sClient.createOrUpdateIngress({
        namespace,
        name: `${project.name}-${environment}`,
        host,
        serviceName: `${project.name}-${environment}`,
        servicePort: 3000,
      })

      job.updateProgress(90)

      // Wait for deployment to be ready
      await k8sClient.waitForDeployment(namespace, `${project.name}-${environment}`, 300)

      job.updateProgress(100)

      // Update deployment as completed
      const completedAt = new Date()
      const durationSeconds = Math.floor(
        (completedAt.getTime() - job.processedOn!) / 1000
      )

      await db.update(deployments)
        .set({
          status: 'completed',
          completedAt,
          url,
          durationSeconds,
        })
        .where(eq(deployments.id, deploymentId))

      // Emit event
      emitWebSocketEvent('deploy:completed', {
        deploymentId,
        environment,
        url,
      })

      logger.info(`Deployment completed: ${environment}${url}`)

      return { success: true, url }
    } catch (error: any) {
      logger.error('Deployment failed:', error)

      // Update as failed
      await db.update(deployments)
        .set({
          status: 'failed',
          errorMessage: error.message,
          completedAt: new Date(),
        })
        .where(eq(deployments.id, deploymentId))

      // Emit event
      emitWebSocketEvent('deploy:failed', {
        deploymentId,
        environment,
        error: error.message,
      })

      throw error
    }
  },
  {
    connection: getRedis(),
    concurrency: 3,
  }
)

Rollback

// api/routes/deployments.ts
router.post('/deployments/:id/rollback', async (req, res) => {
  const { id } = req.params

  // Get deployment
  const deployment = await db.query.deployments.findFirst({
    where: eq(deployments.id, id),
  })

  if (!deployment) {
    return res.status(404).json({ error: 'Deployment not found' })
  }

  // Get previous successful deployment
  const previousDeployment = await db.query.deployments.findFirst({
    where: and(
      eq(deployments.projectId, deployment.projectId),
      eq(deployments.environment, deployment.environment),
      eq(deployments.status, 'completed'),
      lt(deployments.createdAt, deployment.createdAt)
    ),
    orderBy: [desc(deployments.createdAt)],
  })

  if (!previousDeployment) {
    return res.status(400).json({ error: 'No previous deployment to rollback to' })
  }

  // Create rollback deployment
  const rollbackId = crypto.randomUUID()

  await db.insert(deployments).values({
    id: rollbackId,
    projectId: deployment.projectId,
    environment: deployment.environment,
    deploymentType: 'rollback',
    branch: previousDeployment.branch,
    commitHash: previousDeployment.commitHash,
    status: 'pending',
    triggeredBy: req.user?.id,
  })

  // Enqueue
  await enqueueDeploy({
    deploymentId: rollbackId,
    projectId: deployment.projectId,
    environment: deployment.environment,
    branch: previousDeployment.branch!,
    commitHash: previousDeployment.commitHash!,
  })

  res.json({
    deploymentId: rollbackId,
    rollingBackTo: previousDeployment.commitHash,
  })
})

Health Checks Post-Deploy

async function verifyDeployment(url: string): Promise<boolean> {
  const maxAttempts = 10
  const delayMs = 3000

  for (let i = 0; i < maxAttempts; i++) {
    try {
      const response = await fetch(`${url}/health`, {
        method: 'GET',
        signal: AbortSignal.timeout(5000),
      })

      if (response.ok) {
        logger.info(`Deployment healthy: ${url}`)
        return true
      }
    } catch (error) {
      logger.debug(`Health check attempt ${i + 1} failed`)
    }

    await new Promise((resolve) => setTimeout(resolve, delayMs))
  }

  logger.error(`Deployment failed health checks: ${url}`)
  return false
}