name: CI
on:
push:
branches: [main] # Deploy-only jobs (docker); tests already ran on PR
pull_request:
branches: [main] # Full CI: lint, test, build
env:
PYTHON_VERSION: "3.11"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run Ruff linter
run: ruff check src/ tests/
- name: Run Ruff formatter check
run: ruff format --check src/ tests/
typecheck:
name: Type Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
continue-on-error: true # Non-blocking until types are fixed
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run mypy
run: mypy src/contextfs --ignore-missing-imports || echo "Type check failed - see errors above"
test:
name: Test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
services:
chromadb:
image: chromadb/chroma:latest
ports:
- 8000:8000
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: contextfs
POSTGRES_PASSWORD: contextfs_test
POSTGRES_DB: contextfs_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CONTEXTFS_CHROMA_HOST: localhost
CONTEXTFS_CHROMA_PORT: 8000
CONTEXTFS_POSTGRES_URL: postgresql+asyncpg://contextfs:contextfs_test@localhost:5432/contextfs_test
STRIPE_WEBHOOK_SECRET: whsec_test_secret_for_ci
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install asyncpg httpx
- name: Wait for ChromaDB
run: |
for i in {1..30}; do
curl -s http://localhost:8000/api/v2/heartbeat && break || sleep 1
done
- name: Wait for PostgreSQL
run: |
for i in {1..30}; do
pg_isready -h localhost -p 5432 -U contextfs && break || sleep 1
done
- name: Run unit tests
run: pytest tests/unit -v --tb=short
- name: Run integration tests
run: pytest tests/integration -v --tb=short -m "not slow"
test-rag:
name: RAG & Indexing Tests
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [lint, test]
services:
chromadb:
image: chromadb/chroma:latest
ports:
- 8000:8000
env:
CONTEXTFS_CHROMA_HOST: localhost
CONTEXTFS_CHROMA_PORT: 8000
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Wait for ChromaDB
run: |
for i in {1..30}; do
curl -s http://localhost:8000/api/v2/heartbeat && break || sleep 1
done
- name: Run RAG and indexing tests
run: pytest tests/ -v --tb=short -m "slow"
coverage:
name: Coverage
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [test]
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: contextfs
POSTGRES_PASSWORD: contextfs_test
POSTGRES_DB: contextfs_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CONTEXTFS_POSTGRES_URL: postgresql+asyncpg://contextfs:contextfs_test@localhost:5432/contextfs_test
STRIPE_WEBHOOK_SECRET: whsec_test_secret_for_ci
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install asyncpg httpx
- name: Wait for PostgreSQL
run: |
for i in {1..30}; do
pg_isready -h localhost -p 5432 -U contextfs && break || sleep 1
done
- name: Run tests with coverage
run: pytest tests/unit tests/integration -v --cov=src/contextfs --cov-branch --cov-report=xml --cov-report=term -m "not slow"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false
verbose: true
migration-check:
name: Migration Validation
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Check migrations are sequential
run: |
# Verify migration files are numbered sequentially
cd src/contextfs/migrations/versions
ls -1 *.py | grep -v __init__ | sort | head -20
- name: Test migrations up/down
run: |
python -c "
from pathlib import Path
from alembic import command
from src.contextfs.migrations.runner import get_alembic_config, get_current_revision
test_db = Path('/tmp/migration_test.db')
if test_db.exists():
test_db.unlink()
config = get_alembic_config(test_db)
# Upgrade to head
command.upgrade(config, 'head')
current = get_current_revision(test_db)
print(f'Upgraded to: {current}')
# Downgrade to 004
command.downgrade(config, '004')
current = get_current_revision(test_db)
print(f'Downgraded to: {current}')
# Upgrade back to head
command.upgrade(config, 'head')
current = get_current_revision(test_db)
print(f'Re-upgraded to: {current}')
print('Migration test passed!')
"
build:
name: Build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [lint, typecheck, test, migration-check]
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: dist
path: dist/
docker:
name: Build & Push Docker
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.sync
push: true
tags: |
magnetonio/contextfs-sync:latest
magnetonio/contextfs-sync:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy to Railway with rollback
run: |
npm install -g @railway/cli
# Deploy new version
echo "Deploying new version..."
railway redeploy --service sync-api-prod --yes
# Wait for deployment to start
sleep 30
# Health check with retries
echo "Checking deployment health..."
MAX_RETRIES=10
RETRY_DELAY=15
for i in $(seq 1 $MAX_RETRIES); do
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" https://api.contextfs.ai/health || echo "000")
if [ "$HEALTH" = "200" ]; then
echo "Deployment healthy!"
exit 0
fi
echo "Health check attempt $i/$MAX_RETRIES failed (HTTP $HEALTH), waiting ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
# Health check failed - rollback
echo "Deployment failed health checks! Rolling back..."
railway rollback --service sync-api-prod --yes || true
exit 1
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
RAILWAY_PROJECT_ID: bca91012-934d-4d49-a0b5-945764682888
RAILWAY_ENVIRONMENT_ID: a8c658f3-83c3-40aa-91f4-2a10941226cb
notify:
name: Slack Notification
runs-on: ubuntu-latest
needs: [lint, typecheck, test, test-rag, coverage, migration-check, build, docker]
if: always() && (github.ref == 'refs/heads/main' || github.event_name == 'pull_request')
steps:
- name: Determine status
id: status
run: |
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
echo "emoji=:x:" >> $GITHUB_OUTPUT
echo "color=#dc2626" >> $GITHUB_OUTPUT
elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "status=cancelled" >> $GITHUB_OUTPUT
echo "emoji=:warning:" >> $GITHUB_OUTPUT
echo "color=#f59e0b" >> $GITHUB_OUTPUT
else
echo "status=success" >> $GITHUB_OUTPUT
echo "emoji=:white_check_mark:" >> $GITHUB_OUTPUT
echo "color=#16a34a" >> $GITHUB_OUTPUT
fi
- name: Send Slack notification on failure
if: steps.status.outputs.status == 'failure' && env.SLACK_WEBHOOK_URL != ''
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -X POST -H 'Content-type: application/json' \
--data "{
\"text\": \"${{ steps.status.outputs.emoji }} contextfs CI ${{ steps.status.outputs.status }} on ${{ github.ref_name }}\",
\"attachments\": [{
\"color\": \"${{ steps.status.outputs.color }}\",
\"blocks\": [
{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"${{ steps.status.outputs.emoji }} *contextfs* CI ${{ steps.status.outputs.status }}\"
}
},
{
\"type\": \"context\",
\"elements\": [
{
\"type\": \"mrkdwn\",
\"text\": \"*Branch:* \`${{ github.ref_name }}\` | *Commit:* <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|\`${{ github.sha }}\`>\"
}
]
},
{
\"type\": \"context\",
\"elements\": [
{
\"type\": \"mrkdwn\",
\"text\": \"<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>\"
}
]
}
]
}]
}" \
$SLACK_WEBHOOK_URL