# Unified CI Pipeline - Mosaic Stack # Single install, parallel quality gates, sequential deploy # # Replaces: api.yml, orchestrator.yml, web.yml # Keeps: coordinator.yml (Python), infra.yml (separate concerns) # # Flow: # install → security-audit # → prisma-generate → lint + typecheck (parallel) # → prisma-migrate → test # → build (after all gates pass) # → docker builds (main only, parallel) # → trivy scans (main only, parallel) # → package linking (main only) when: - event: [push, pull_request, manual] path: include: - "apps/api/**" - "apps/orchestrator/**" - "apps/web/**" - "packages/**" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - "turbo.json" - "package.json" - ".woodpecker/ci.yml" - ".trivyignore" variables: - &node_image "node:24-alpine" - &install_deps | corepack enable pnpm config set store-dir /root/.local/share/pnpm/store pnpm install --frozen-lockfile - &use_deps | corepack enable - &turbo_env TURBO_API: from_secret: turbo_api TURBO_TOKEN: from_secret: turbo_token TURBO_TEAM: from_secret: turbo_team - &kaniko_setup | mkdir -p /kaniko/.docker echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json services: postgres: image: postgres:17.7-alpine3.22 environment: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password steps: # ─── Install (once) ───────────────────────────────────────── install: image: *node_image commands: - *install_deps # ─── Security Audit (once) ────────────────────────────────── security-audit: image: *node_image commands: - *use_deps - pnpm audit --audit-level=high depends_on: - install # ─── Prisma Generate ──────────────────────────────────────── prisma-generate: image: *node_image environment: SKIP_ENV_VALIDATION: "true" commands: - *use_deps - pnpm --filter "@mosaic/api" prisma:generate depends_on: - install # ─── Lint (all packages) ──────────────────────────────────── lint: image: *node_image environment: SKIP_ENV_VALIDATION: "true" <<: *turbo_env commands: - *use_deps - pnpm turbo lint depends_on: - prisma-generate # ─── Typecheck (all packages, parallel with lint) ─────────── typecheck: image: *node_image environment: SKIP_ENV_VALIDATION: "true" <<: *turbo_env commands: - *use_deps - pnpm turbo typecheck depends_on: - prisma-generate # ─── Prisma Migrate (test DB) ────────────────────────────── prisma-migrate: image: *node_image environment: SKIP_ENV_VALIDATION: "true" DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public" commands: - *use_deps - pnpm --filter "@mosaic/api" prisma migrate deploy depends_on: - prisma-generate # ─── Test (all packages) ─────────────────────────────────── test: image: *node_image environment: SKIP_ENV_VALIDATION: "true" DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public" ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" <<: *turbo_env commands: - *use_deps - pnpm --filter "@mosaic/api" exec vitest run --exclude 'src/auth/auth-rls.integration.spec.ts' --exclude 'src/credentials/user-credential.model.spec.ts' --exclude 'src/job-events/job-events.performance.spec.ts' --exclude 'src/knowledge/services/fulltext-search.spec.ts' --exclude 'src/mosaic-telemetry/mosaic-telemetry.module.spec.ts' - pnpm turbo test --filter=@mosaic/orchestrator --filter=@mosaic/web depends_on: - prisma-migrate # ─── Build (all packages) ────────────────────────────────── build: image: *node_image environment: SKIP_ENV_VALIDATION: "true" NODE_ENV: "production" <<: *turbo_env commands: - *use_deps - pnpm turbo build depends_on: - lint - typecheck - test - security-audit # ─── Docker Builds (main only, parallel) ─────────────────── docker-build-api: image: gcr.io/kaniko-project/executor:debug environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - *kaniko_setup - | DESTINATIONS="" if [ -n "$CI_COMMIT_TAG" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG" elif [ "$CI_COMMIT_BRANCH" = "main" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest" fi /kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS when: - branch: [main] event: [push, manual, tag] depends_on: - build docker-build-orchestrator: image: gcr.io/kaniko-project/executor:debug environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - *kaniko_setup - | DESTINATIONS="" if [ -n "$CI_COMMIT_TAG" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG" elif [ "$CI_COMMIT_BRANCH" = "main" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest" fi /kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS when: - branch: [main] event: [push, manual, tag] depends_on: - build docker-build-web: image: gcr.io/kaniko-project/executor:debug environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - *kaniko_setup - | DESTINATIONS="" if [ -n "$CI_COMMIT_TAG" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG" elif [ "$CI_COMMIT_BRANCH" = "main" ]; then DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest" fi /kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-web/cache --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS when: - branch: [main] event: [push, manual, tag] depends_on: - build # ─── Container Security Scans (main only) ────────────────── security-trivy-api: image: aquasec/trivy:latest environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - | if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi mkdir -p ~/.docker echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG when: - branch: [main] event: [push, manual, tag] depends_on: - docker-build-api security-trivy-orchestrator: image: aquasec/trivy:latest environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - | if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi mkdir -p ~/.docker echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG when: - branch: [main] event: [push, manual, tag] depends_on: - docker-build-orchestrator security-trivy-web: image: aquasec/trivy:latest environment: GITEA_USER: from_secret: gitea_username GITEA_TOKEN: from_secret: gitea_token CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} commands: - | if [ -n "$$CI_COMMIT_TAG" ]; then SCAN_TAG="$$CI_COMMIT_TAG"; else SCAN_TAG="latest"; fi mkdir -p ~/.docker echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --ignorefile .trivyignore git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG when: - branch: [main] event: [push, manual, tag] depends_on: - docker-build-web # ─── Package Linking (main only, once) ───────────────────── link-packages: image: alpine:3 environment: GITEA_TOKEN: from_secret: gitea_token commands: - apk add --no-cache curl - sleep 10 - | set -e link_package() { PKG="$$1" echo "Linking $$PKG..." for attempt in 1 2 3; do STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \ -H "Authorization: token $$GITEA_TOKEN" \ "https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack") if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then echo " Linked $$PKG" return 0 elif [ "$$STATUS" = "400" ]; then echo " $$PKG already linked" return 0 elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..." sleep 5 else echo " FAILED: $$PKG status $$STATUS" cat /tmp/link-response.txt return 1 fi done } link_package "stack-api" link_package "stack-orchestrator" link_package "stack-web" when: - branch: [main] event: [push, manual, tag] depends_on: - security-trivy-api - security-trivy-orchestrator - security-trivy-web