# Preloop Open Source CI/CD
# This pipeline builds and tests the open-source core.
# For enterprise builds and production deployments, see preloop-ee/.gitlab-ci.yml
image: alpine:3.22
variables:
CI_REGISTRY_IMAGE: $CI_REGISTRY/spacecode/preloop
CI_REGISTRY_IMAGE_FRONTEND: $CI_REGISTRY/spacecode/preloop/frontend
GITLAB_TOKEN: $GITLAB_TOKEN
PRELOOP_TEST_PASSWORD: $PRELOOP_TEST_PASSWORD
PRELOOP_TEST_USERNAME: $PRELOOP_TEST_USERNAME
PRELOOP_TEST_EMAIL: $PRELOOP_TEST_EMAIL
PRELOOP_TEST_API_KEY: $PRELOOP_TEST_API_KEY
FF_TIMESTAMPS: "true"
cache:
paths:
- .pip-cache/
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
stages:
- build
- test
- deploy
- integration
- cleanup
# =============================================================================
# BUILD STAGE
# =============================================================================
build:backend:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 -w 0)\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $CI_PROJECT_DIR/Dockerfile
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--destination $CI_REGISTRY_IMAGE:latest
--cache=true
--cache-repo $CI_REGISTRY_IMAGE/cache
--cache-ttl 168h
--build-arg GITLAB_TOKEN=$GITLAB_TOKEN
build:frontend:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 -w 0)\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context $CI_PROJECT_DIR/frontend
--dockerfile $CI_PROJECT_DIR/frontend/Dockerfile
--build-arg BRAND=preloop
--destination $CI_REGISTRY_IMAGE_FRONTEND:$CI_COMMIT_SHA
--destination $CI_REGISTRY_IMAGE_FRONTEND:latest
--cache=true
--cache-repo $CI_REGISTRY_IMAGE_FRONTEND/cache
--cache-ttl 168h
rules:
- changes:
- frontend/**/*
when: always
- when: always # Always build on first run, cache handles efficiency
# =============================================================================
# TEST STAGE - Unit Tests & Static Analysis
# =============================================================================
pre-commit:
stage: test
needs:
- build:backend
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
before_script:
- apt-get update && apt-get install -y git nodejs npm
- pip install -U pip pre-commit ruff
- pip install -e ".[dev]"
- npm install -g prettier
script:
- pre-commit install
- sleep 1
- pre-commit run --all-files --show-diff
- ruff check
frontend:format-check:
stage: test
needs: []
image: node:22-alpine
before_script:
- cd frontend && npm install
script:
- npm run format:check
rules:
- changes:
- frontend/**/*
frontend:unit-tests:
stage: test
needs: []
image: mcr.microsoft.com/playwright:v1.58.0-noble
before_script:
- cd frontend && npm install
script:
- npm run test
rules:
- changes:
- frontend/**/*
.test:unit:template:
stage: test
needs:
- build:backend
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
services:
- name: pgvector/pgvector:pg13
alias: postgres
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
DATABASE_URL: postgresql://test_user:test_password@postgres:5432/test_db
SENTRY_DSN:
before_script:
- pip install -U pip
- pip install pytest pytest-cov loguru
- pip install -e ".[dev]"
- python ./scripts/init_db.py --force
script:
- export ANTHROPIC_API_KEY="mock_openai_key"
- export PYTHONPATH=$PYTHONPATH:.
- export COVERAGE_FILE=".coverage.${CI_JOB_NAME}"
- pytest -v --cov --cov-report= --cov-context=test --junitxml="report-${CI_JOB_NAME}.xml" $TEST_PATHS
artifacts:
paths:
- ".coverage.*"
- "report-*.xml"
expire_in: 1 day
test:unit:preloop-models:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/models"
# test:unit:mcp:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "mcp/tests"
test:unit:preloop-sync:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/sync"
test:unit:preloop-other:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/test_*.py backend/tests/tools backend/tests/services backend/tests/utils backend/tests/middleware backend/tests/test_plugin_system"
test:unit:preloop-endpoints-ai-models:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_ai_models.py"
test:unit:preloop-endpoints-auth:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_auth.py"
test:unit:preloop-endpoints-comments:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_comments.py"
test:unit:preloop-endpoints-embedding:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_embedding.py"
test:unit:preloop-endpoints-flows:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_flows.py"
test:unit:preloop-endpoints-health:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_health.py"
# test:unit:preloop-endpoints-issue-compliance:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "tests/endpoints/test_issue_compliance.py"
test:unit:preloop-endpoints-issue-dependencies:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_issue_dependencies.py"
test:unit:preloop-endpoints-issue-duplicates:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_issue_duplicates.py"
test:unit:preloop-endpoints-issues:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_issues.py"
# test:unit:preloop-endpoints-organizations:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "tests/endpoints/test_organizations.py"
# test:unit:preloop-endpoints-projects:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "tests/endpoints/test_projects.py"
test:unit:preloop-endpoints-search:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_search.py"
test:unit:preloop-endpoints-trackers:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_trackers.py"
# test:unit:preloop-endpoints-version:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "tests/endpoints/test_version.py"
test:unit:preloop-endpoints-webhooks:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_webhooks.py"
test:unit:preloop-endpoints-mcp:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_mcp.py"
test:unit:preloop-endpoints-mcp-servers:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_mcp_servers.py"
test:unit:preloop-endpoints-tools:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_tools.py"
test:unit:preloop-endpoints-api-keys:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_api_keys.py"
test:unit:preloop-endpoints-approval-requests:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_approval_requests.py"
test:unit:preloop-endpoints-auth-comprehensive:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_auth_comprehensive.py"
test:unit:preloop-endpoints-features:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_features.py"
test:unit:preloop-endpoints-jwt:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_jwt.py"
test:unit:preloop-endpoints-notification-preferences:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_notification_preferences.py"
test:unit:preloop-endpoints-organizations:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_organizations.py"
test:unit:preloop-endpoints-projects:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_projects.py"
test:unit:preloop-endpoints-websockets:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/endpoints/test_websockets.py"
# test:unit:preloop-endpoints-issue-duplicate-stats:
# NOTE: This test was moved to plugins/analytics/tests/test_issue_duplicate_stats.py
# and is now run as part of the plugin tests
# extends: .test:unit:template
# variables:
# TEST_PATHS: "backend/tests/endpoints/test_issue_duplicate_stats.py"
# test:unit:preloop-endpoints-websockets:
# extends: .test:unit:template
# variables:
# TEST_PATHS: "tests/endpoints/test_websockets.py"
test:unit:preloop-agents:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/agents"
test:unit:preloop-api:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/api"
test:unit:preloop-scripts:
extends: .test:unit:template
variables:
TEST_PATHS: "backend/tests/scripts"
test:coverage:
stage: test
needs:
- test:unit:preloop-models
# - test:unit:mcp
- test:unit:preloop-sync
- test:unit:preloop-other
- test:unit:preloop-endpoints-ai-models
- test:unit:preloop-endpoints-auth
- test:unit:preloop-endpoints-comments
- test:unit:preloop-endpoints-embedding
- test:unit:preloop-endpoints-flows
- test:unit:preloop-endpoints-health
# - test:unit:preloop-endpoints-issue-compliance
- test:unit:preloop-endpoints-issue-dependencies
- test:unit:preloop-endpoints-issue-duplicates
- test:unit:preloop-endpoints-issues
- test:unit:preloop-endpoints-organizations
- test:unit:preloop-endpoints-projects
- test:unit:preloop-endpoints-search
- test:unit:preloop-endpoints-trackers
# - test:unit:preloop-endpoints-version
- test:unit:preloop-endpoints-webhooks
- test:unit:preloop-endpoints-mcp
- test:unit:preloop-endpoints-mcp-servers
- test:unit:preloop-endpoints-tools
- test:unit:preloop-endpoints-api-keys
- test:unit:preloop-endpoints-approval-requests
- test:unit:preloop-endpoints-auth-comprehensive
- test:unit:preloop-endpoints-features
- test:unit:preloop-endpoints-jwt
- test:unit:preloop-endpoints-notification-preferences
- test:unit:preloop-endpoints-websockets
# - test:unit:preloop-endpoints-issue-duplicate-stats
- test:unit:preloop-agents
- test:unit:preloop-api
- test:unit:preloop-scripts
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
before_script:
- pip install coverage junitparser
script:
- ls -la
- |
python -c "
import glob
from junitparser import JUnitXml, TestSuite
merged_suite = TestSuite('merged-results')
total_tests = 0
total_failures = 0
total_errors = 0
total_skipped = 0
xml_files = glob.glob('report-*.xml')
if not xml_files:
print('No JUnit XML reports found to merge.')
else:
for file_path in xml_files:
try:
xml = JUnitXml.fromfile(file_path)
for suite in xml:
merged_suite.add_testsuite(suite)
except Exception as e:
print(f'Error processing file {file_path}: {e}')
merged_suite.update_statistics()
total_tests = merged_suite.tests
total_failures = merged_suite.failures
total_errors = merged_suite.errors
total_skipped = merged_suite.skipped
merged_suite.write('merged-report.xml')
print('================ Test Summary ================')
print(f'Total tests: {total_tests}')
print(f'Failures: {total_failures}')
print(f'Errors: {total_errors}')
print(f'Skipped: {total_skipped}')
print('==============================================')
"
- echo "Checking coverage files before combine..."
- ls -lh .coverage.* || echo "No coverage files found"
- |
python -c "
import sqlite3
import sys
print('Checking first coverage file...')
try:
conn = sqlite3.connect('.coverage.test:unit:preloop-agents')
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM file')
file_count = cursor.fetchone()[0]
print(f'Files tracked in coverage: {file_count}')
if file_count > 0:
cursor.execute('SELECT path FROM file LIMIT 5')
print('Sample files:', cursor.fetchall())
cursor.execute('SELECT count(*) FROM line_bits')
line_count = cursor.fetchone()[0]
print(f'Line entries: {line_count}')
conn.close()
except Exception as e:
print(f'Error reading coverage file: {e}')
sys.exit(0)
"
- coverage combine
- echo "After combine, checking combined coverage..."
- coverage report --fail-under=60
- coverage xml -o coverage.xml
artifacts:
reports:
junit: merged-report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
# =============================================================================
# DEPLOY STAGE - Test Environments for Integration Testing
# =============================================================================
.deploy:template:
stage: deploy
image: dtzar/helm-kubectl:3.13.2
when: manual
environment:
action: start
variables:
DATABASE_NAME: ""
SEED_ENABLED: "false"
ENV_NAME: ""
script:
- helm repo add nats https://nats-io.github.io/k8s/helm/charts
- helm dependency update ./helm/preloop
- helm dependency build ./helm/preloop
- helm upgrade --install ${ENV_NAME} ./helm/preloop
--namespace ${NAMESPACE}
--set image.tag=${CI_COMMIT_SHA}
--set console.tag=${CI_COMMIT_SHA}
--set console.repository=${CI_REGISTRY_IMAGE_FRONTEND}
--set config.openai.apiKey=${OPENAI_API_KEY}
--set config.anthropic.apiKey=${ANTHROPIC_API_KEY}
--set config.smtp.host=${SMTP_HOST}
--set config.smtp.port=${SMTP_PORT}
--set config.smtp.username=${SMTP_USERNAME}
--set config.smtp.password=${SMTP_PASSWORD}
--set config.smtp.from=${SMTP_FROM}
--set config.smtp.fromName="${SMTP_FROM_NAME}"
--set sentry.enabled=false
--set profiling.enabled=false
--set stripe.enabled=false
--set worker.pools[0].name=polling
--set worker.pools[0].replicaCount=1
--set worker.pools[0].tasks=poll_tracker
--set worker.pools[1].name=maintenance
--set worker.pools[1].replicaCount=1
--set worker.pools[1].tasks[0]=notify_admins
--set worker.pools[1].tasks[1]=cleanup_tracker_webhooks
--set worker.pools[2].name=webhook
--set worker.pools[2].replicaCount=1
--set worker.pools[2].tasks=process_webhook_event
--set seed.enabled=${SEED_ENABLED}
--set seed.email=${PRELOOP_TEST_EMAIL}
--set seed.username=${PRELOOP_TEST_USERNAME}
--set seed.password=${PRELOOP_TEST_PASSWORD}
--set seed.apiKey=${PRELOOP_TEST_API_KEY}
--set database.name=${DATABASE_NAME}
--set ingress.enabled=true
--set ingress.hosts[0].host=${TARGET_DOMAIN}
--set ingress.hosts[0].paths[0].path=/
--set ingress.hosts[0].paths[0].pathType=ImplementationSpecific
--set ingress.tls[0].hosts[0]=${TARGET_DOMAIN}
--set ingress.tls[0].secretName=preloop-${ENV_NAME}-tls
--set environment.preloopUrl=https://${TARGET_DOMAIN}
--set database.cnpg.name=${DATABASE_NAME}
--set imagePullSecrets[0].name=registry-secret
--set apns.enabled=${APNS_ENABLED:-true}
--set apns.teamId="${APNS_TEAM_ID}"
--set apns.keyId="${APNS_KEY_ID}"
--set-string apns.authKey="${APNS_AUTH_KEY}"
--set apns.bundleId="${APNS_BUNDLE_ID}"
--set apns.useSandbox=${APNS_USE_SANDBOX:-true}
--set fcm.enabled=${FCM_ENABLED:-false}
--set-string fcm.credentialsJson="${FCM_CREDENTIALS_JSON}"
- echo "Helm deployment finished."
- echo "Checking deployed environment..."
- for i in $(seq 1 12); do
curl -f https://${TARGET_DOMAIN}/api/v1/health && break;
sleep 5;
done
- if [ $? -eq 0 ]; then echo "Deployed environment is ready."; else echo "Deployed environment is not ready."; fi
deploy:test:
extends: .deploy:template
needs:
- build:backend
- build:frontend
when: on_success
stage: deploy
variables:
NAMESPACE: preloop-oss-test
SEED_ENABLED: "true"
ENV_NAME: "oss-$CI_COMMIT_REF_NAME"
TARGET_DOMAIN: "oss-$CI_COMMIT_REF_NAME.test.preloop.ai"
environment:
name: oss-test/$CI_COMMIT_REF_NAME
url: https://oss-$CI_COMMIT_REF_NAME.test.preloop.ai
on_stop: stop-test-env
auto_stop_in: 2 hours
deploy:review:
extends: .deploy:template
needs:
- build:backend
- build:frontend
environment:
name: oss-review/$CI_COMMIT_REF_NAME
url: https://oss-$CI_COMMIT_REF_NAME.review.preloop.ai
on_stop: stop-review-env
variables:
NAMESPACE: preloop-review
ENV_NAME: "oss-$CI_COMMIT_REF_NAME"
SEED_ENABLED: "true"
TARGET_DOMAIN: "oss-$CI_COMMIT_REF_NAME.review.preloop.ai"
# =============================================================================
# INTEGRATION STAGE - End-to-End Tests on Deployed Environment
# =============================================================================
.test:integration:template:
stage: integration
needs:
- deploy:test
image: mcr.microsoft.com/playwright/python:v1.30.0-focal
variables:
PRELOOP_TEST_URL: "https://oss-$CI_COMMIT_REF_NAME.test.preloop.ai"
before_script:
- pip install pytest httpx playwright fastapi dotenv sqlalchemy pytest-playwright
- playwright install
- cd backend
test:integration:ui:
extends: .test:integration:template
script:
- pytest --confcutdir=tests/integration tests/integration/test_ui.py
artifacts:
when: on_failure
paths:
- backend/tests/integration/screenshots/
expire_in: 7 days
.test:integration:api:template:
extends: .test:integration:template
image: python:3.12-alpine
before_script:
- pip install pytest httpx dotenv mcp
- cd backend
test:integration:api:github:
extends: .test:integration:api:template
script:
- pytest --confcutdir=tests/integration tests/integration/test_tracker_sync_github.py -v -s
test:integration:api:gitlab:
extends: .test:integration:api:template
script:
- pytest --confcutdir=tests/integration tests/integration/test_tracker_sync_gitlab.py -v -s
test:integration:api:jira:
extends: .test:integration:api:template
script:
- pytest --confcutdir=tests/integration tests/integration/test_tracker_sync_jira.py -v -s
# =============================================================================
# CLEANUP STAGE
# =============================================================================
.stop-env:template:
stage: cleanup
needs:
- test:integration:ui
- test:integration:api:github
- test:integration:api:gitlab
- test:integration:api:jira
image: dtzar/helm-kubectl:3.13.2
when: on_success
allow_failure: true
environment:
action: stop
script:
- helm delete ${ENV_NAME} --namespace $NAMESPACE || true
- kubectl delete job --namespace $NAMESPACE ${ENV_NAME}-preloop-migration-job ${ENV_NAME}-preloop-init-db-job ${ENV_NAME}-preloop-webhook-cleanup-job || true
stop-test-env:
extends: .stop-env:template
variables:
NAMESPACE: "preloop-oss-test"
ENV_NAME: "oss-$CI_COMMIT_REF_NAME"
environment:
name: oss-test/$CI_COMMIT_REF_NAME
stop-review-env:
extends: .stop-env:template
needs: []
when: manual
variables:
NAMESPACE: "preloop-review"
ENV_NAME: "oss-$CI_COMMIT_REF_NAME"
environment:
name: oss-review/$CI_COMMIT_REF_NAME
smoke:
stage: integration
image: mcr.microsoft.com/playwright/python:v1.30.0-focal
script:
- pip install -e ".[dev]"
- playwright install
- pytest tests/smoke
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"