name: CI/CD

on:
  workflow_dispatch:
  push:
    branches:
      - "main"
    tags:
      - "v*"
  merge_group:
  pull_request:
    branches:
      - "**"

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  pre-job:
    runs-on: ubuntu-latest
    outputs:
      should_skip: ${{ steps.skip_check.outputs.should_skip }}
    timeout-minutes: 15
    steps:
      - id: skip_check
        uses: fkirc/skip-duplicate-actions@v5
        with:
          do_not_skip: '["workflow_dispatch"]'

  lint:
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm i
      - name: Load default env
        run: |
          cp .env.dev.example .env
      - name: lint web
        run: pnpm run lint

  prettier-check:
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm i
      - name: Load default env
        run: |
          cp .env.dev.example .env
      - name: Check formatting on changed files
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE_SHA=${{ github.event.pull_request.base.sha }}
          else
            BASE_SHA=$(git merge-base origin/main HEAD)
          fi

          echo "Checking files changed from $BASE_SHA to HEAD"

          # Get changed files
          CHANGED_FILES=$(git diff --name-only $BASE_SHA HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.css' | tr '\n' ' ')

          if [ -n "$CHANGED_FILES" ] && [ "$CHANGED_FILES" != " " ]; then
            echo "Files to check: $CHANGED_FILES"
            pnpm prettier --check --experimental-cli $CHANGED_FILES
          else
            echo "No JS/TS/CSS files changed - skipping prettier check"
          fi

  test-docker-build:
    timeout-minutes: 20
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - name: Set NEXT_PUBLIC_BUILD_ID
        run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
      - name: Build and run both images from compose
        run: |
          docker compose --progress plain --verbose -f docker-compose.build.yml build --print > /tmp/bake.json
          docker buildx bake -f /tmp/bake.json
          docker compose --progress plain -f docker-compose.build.yml up -d
          sleep 5 # Wait for PostgreSQL to accept connections
      - name: Ensure no unhealthy status
        run: |
          if docker-compose ps | grep "(unhealthy)"; then
            echo "One or more services are unhealthy"
            exit 1
          else
            echo "All services are healthy"
          fi
      - name: Check worker health
        run: |
          timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
      - name: Check server health
        run: |
          timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'

  tests-web-sync:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    name: tests-web-sync (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
    strategy:
      matrix:
        node-version: [24]
        postgres-version: [12, 15]
    steps:
      - uses: actions/checkout@v4
      - name: Install golang-migrate for Clickhouse migrations
        run: |
          curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
          sudo mv migrate /usr/bin/migrate
          which migrate
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm install
      - name: Load default env
        run: |
          cp .env.dev.example .env
          grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
          echo "LANGFUSE_INGESTION_QUEUE_DELAY_MS=1" >> .env
          echo "LANGFUSE_CACHE_PROMPT_ENABLED=false" >> .env
          echo "LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1" >> .env
      - name: Run dev containers
        run: |
          docker compose -f docker-compose.dev.yml up -d
          sleep 5 # Wait for PostgreSQL to accept connections
          docker compose ps
        env:
          POSTGRES_VERSION: ${{ matrix.postgres-version }}
      - name: Migrate DB
        run: |
          pnpm run db:migrate
          pnpm --filter=shared ch:up
      - name: Build
        run: pnpm run build
        env:
          NODE_OPTIONS: --max_old_space_size=8192
      - name: Start Langfuse
        run: (pnpm run start&)
        env:
          LANGFUSE_INIT_ORG_ID: "seed-org-id"
          LANGFUSE_INIT_ORG_NAME: "Seed Org"
          LANGFUSE_INIT_ORG_CLOUD_PLAN: "Team"
          LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
          LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
          LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
          LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
          LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
          LANGFUSE_INIT_USER_NAME: "Demo User"
          LANGFUSE_INIT_USER_PASSWORD: "password"
      - name: run test-sync
        run: pnpm --filter=web run test-sync
      - name: run test-client
        run: pnpm --filter=web run test-client

  tests-web-async:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
    strategy:
      matrix:
        node-version: [24]
        postgres-version: [12, 15]
        deploy-mode: ["", "-azure", "-redis-cluster"]
    steps:
      - uses: actions/checkout@v4
      - name: Install golang-migrate for Clickhouse migrations
        run: |
          curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
          sudo mv migrate /usr/bin/migrate
          which migrate
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm install
      - name: Load default env
        run: |
          cp .env.dev${{ matrix.deploy-mode }}.example .env
          grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.deploy-mode }}.example > .env
          echo "LANGFUSE_INGESTION_QUEUE_DELAY_MS=1" >> .env
          echo "LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1" >> .env
          echo "LANGFUSE_TRACE_DELETE_CONCURRENCY=100" >> .env
          echo "ADMIN_API_KEY=admin-api-key" >> .env
          echo "LANGFUSE_EE_LICENSE_KEY=langfuse_ee_test" >> .env
      - name: Run dev containers
        run: |
          docker compose -f docker-compose.dev${{ matrix.deploy-mode }}.yml up -d
          sleep 5 # Wait for PostgreSQL to accept connections
          docker compose ps
        env:
          POSTGRES_VERSION: ${{ matrix.postgres-version }}
      - name: Migrate DB
        run: |
          pnpm run db:migrate
          pnpm --filter=shared ch:up
      - name: Build
        run: pnpm run build
        env:
          NODE_OPTIONS: --max_old_space_size=8192
      - name: Start Langfuse
        run: (pnpm run start&)
        env:
          LANGFUSE_INIT_ORG_ID: "seed-org-id"
          LANGFUSE_INIT_ORG_NAME: "Seed Org"
          LANGFUSE_INIT_ORG_CLOUD_PLAN: "Team"
          LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
          LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
          LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
          LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
          LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
          LANGFUSE_INIT_USER_NAME: "Demo User"
          LANGFUSE_INIT_USER_PASSWORD: "password"
      - name: run tests
        run: pnpm --filter=web run test

  tests-worker:
    timeout-minutes: 20
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
    strategy:
      matrix:
        node-version: [24]
        postgres-version: [12, 15]
        deploy-mode: ["", "-azure", "-redis-cluster"]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm install
      - name: Install golang-migrate for Clickhouse migrations
        run: |
          curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
          sudo mv migrate /usr/bin/migrate
          which migrate
      - name: Load default env
        run: |
          cp .env.dev${{ matrix.deploy-mode }}.example .env
          cp .env.dev${{ matrix.deploy-mode }}.example web/.env
          cp .env.dev${{ matrix.deploy-mode }}.example worker/.env
      - name: Run + migrate
        run: |
          docker compose -f docker-compose.dev${{ matrix.deploy-mode }}.yml up -d
          sleep 5 # Wait for PostgreSQL to accept connections
          docker compose ps
      - name: Ensure no unhealthy status
        run: |
          if docker compose ps | grep "(unhealthy)"; then
            echo "One or more services are unhealthy"
            exit 1
          else
            echo "All services are healthy"
          fi
      - name: Seed DB
        run: |
          pnpm run db:migrate
          pnpm run db:seed
          pnpm run --filter=shared ch:up
      - name: Build
        run: pnpm --filter=worker... run build
      - name: run tests
        run: pnpm --filter=worker run test

  e2e-tests:
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - name: install dependencies
        run: |
          pnpm install
      - name: Load default env
        run: |
          cp .env.dev.example .env
          cp .env.dev.example web/.env
      - name: Install golang-migrate for Clickhouse migrations
        run: |
          curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
          sudo mv migrate /usr/bin/migrate
          which migrate
      - name: Run + migrate
        run: |
          docker compose -f docker-compose.dev.yml up -d
          docker compose ps
          sleep 5 # Wait for PostgreSQL to accept connections
      - name: Ensure Docker dependencies are healthy
        run: |
          if docker-compose ps | grep "(unhealthy)"; then
            echo "One or more services are unhealthy"
            exit 1
          else
            echo "All services are healthy"
          fi
      - name: Seed DB
        run: |
          pnpm run db:migrate
          pnpm --filter=shared run ch:up
          pnpm run db:seed
      - name: Build
        run: pnpm run build
        env:
          NODE_OPTIONS: --max_old_space_size=8192
      - name: Install playwright
        run: pnpm --filter=web exec playwright install --with-deps
      - name: Run e2e tests
        run: pnpm --filter=web run test:e2e

  e2e-server-tests:
    runs-on: ubuntu-latest
    needs:
      - pre-job
    if: needs.pre-job.outputs.should_skip != 'true'
    steps:
      - uses: actions/checkout@v4
      - name: Login to Docker Hub
        if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
          password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"
      - name: install dependencies
        run: |
          pnpm install
      - name: Install golang-migrate for Clickhouse migrations
        run: |
          curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
          sudo mv migrate /usr/bin/migrate
          which migrate
      - name: Load default env
        run: |
          cp .env.dev.example .env
      - name: Run + migrate
        run: |
          docker compose -f docker-compose.dev.yml up -d
          docker compose ps
          sleep 5 # Wait for PostgreSQL to accept connections
      - name: Ensure Docker dependencies are healthy
        run: |
          if docker-compose ps | grep "(unhealthy)"; then
            echo "One or more services are unhealthy"
            exit 1
          else
            echo "All services are healthy"
          fi
      - name: Seed DB
        run: |
          pnpm run db:migrate
          pnpm --filter=shared run ch:up
          pnpm run db:seed:examples
      - name: Build
        run: pnpm run build
        env:
          NODE_OPTIONS: --max_old_space_size=8192
      - name: Run server
        run: (pnpm run start&)
      - name: Check worker health
        run: |
          timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
      - name: Check server health
        run: |
          timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
      - name: Run e2e tests
        run: pnpm --filter=web run test:e2e:server

  all-ci-passed:
    # This allows us to have a branch protection rule for tests and deploys with matrix
    runs-on: ubuntu-latest
    needs:
      [
        lint,
        prettier-check,
        tests-web-sync,
        tests-worker,
        e2e-tests,
        test-docker-build,
        e2e-server-tests,
        tests-web-async,
      ]
    if: always()
    steps:
      - name: Successful deploy
        if: ${{ !(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
        run: exit 0
        working-directory: .
      - name: Failing deploy
        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: exit 1
        working-directory: .
      - name: Notify Slack
        uses: ravsamhq/notify-slack-action@v2
        if: always() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
        with:
          status: ${{ job.status }}
          notify_when: "failure"
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

  push-docker-image:
    needs: all-ci-passed
    if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
    environment: "protected branches"
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read

    steps:
      - uses: pnpm/action-setup@v3
        with:
          version: 9.5.0
      - name: Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 24
          cache-dependency-path: "pnpm-lock.yaml"
      - name: Checkout
        uses: actions/checkout@v4
      - name: Set NEXT_PUBLIC_BUILD_ID
        run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
      - name: Log in to the GitHub Container registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: network=host
      - name: Extract metadata (tags, labels) for Docker
        id: meta-web
        uses: docker/metadata-action@v4
        with:
          images: |
            ghcr.io/langfuse/langfuse # GitHub
            langfuse/langfuse # Docker Hub
          flavor: |
            latest=false
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
            type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
      - name: Build and push Docker image (web)
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./web/Dockerfile
          push: true
          tags: ${{ steps.meta-web.outputs.tags }}
          labels: ${{ steps.meta-web.outputs.labels }}
          platforms: |
            linux/amd64
            ${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
      - name: Extract metadata (tags, labels) for Docker
        id: meta-worker
        uses: docker/metadata-action@v4
        with:
          images: |
            ghcr.io/langfuse/langfuse-worker # GitHub
            langfuse/langfuse-worker # Docker Hub
          flavor: |
            latest=false
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
            type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
      - name: Build and push Docker image (worker)
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./worker/Dockerfile
          push: true
          tags: ${{ steps.meta-worker.outputs.tags }}
          labels: ${{ steps.meta-worker.outputs.labels }}
          platforms: |
            linux/amd64
            ${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
      - name: Notify Slack
        uses: ravsamhq/notify-slack-action@v2
        if: always()
        with:
          status: ${{ job.status }}
          notify_when: "failure"
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
