# ============================================================ # CI Pipeline — lint + tests + Docker build # Runs on every push and pull request # ============================================================ name: CI on: push: branches: ["**"] pull_request: branches: [main, master, develop] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true env: PYTHON_VERSION: "3.12" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: # ---------------------------------------------------------- # Job 1: Lint & Static Analysis # ---------------------------------------------------------- lint: name: Lint & Security Scan runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} cache: pip - name: Install lint tools run: pip install flake8 bandit safety - name: Flake8 linting run: | flake8 . \ --exclude=venv,migrations,__pycache__,.git \ --max-line-length=120 \ --ignore=E501,W503,E302,E303 \ --count --statistics continue-on-error: true - name: Bandit security scan run: | bandit -r . \ --exclude ./venv,./migrations,./infra \ -ll -ii \ -f json -o bandit-report.json || true cat bandit-report.json - name: Safety dependency vulnerability check run: | safety check -r requirements.txt --json || true # ---------------------------------------------------------- # Job 2: Tests # ---------------------------------------------------------- test: name: Unit & Integration Tests runs-on: ubuntu-latest needs: lint services: postgres: image: postgres:16-alpine env: POSTGRES_DB: turf_test POSTGRES_USER: turf POSTGRES_PASSWORD: testpassword ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: DATABASE_URL: postgresql://turf:testpassword@localhost:5432/turf_test POSTGRES_HOST: localhost POSTGRES_PORT: 5432 POSTGRES_DB: turf_test POSTGRES_USER: turf POSTGRES_PASSWORD: testpassword FLASK_ENV: testing SECRET_KEY: test-secret-key-not-for-production DB_PATH: /tmp/turf_test.db LOG_LEVEL: WARNING steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} cache: pip - name: Install dependencies run: pip install -r requirements.txt pytest pytest-cov pytest-flask - name: Run Alembic migrations run: | if [ -f alembic.ini ]; then alembic upgrade head else echo "No alembic.ini found, skipping migrations" fi - name: Run tests run: | if [ -d tests ]; then pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing else echo "No tests directory found — creating basic smoke test" python -c " import sys, os os.environ['FLASK_ENV'] = 'testing' os.environ['SECRET_KEY'] = 'test' os.environ['DB_PATH'] = '/tmp/smoke_test.db' print('Import check...') try: import combined_api print('combined_api: OK') except Exception as e: print(f'combined_api: ERROR - {e}') try: import dashboard_api print('dashboard_api: OK') except Exception as e: print(f'dashboard_api: ERROR - {e}') try: import portal_server print('portal_server: OK') except Exception as e: print(f'portal_server: ERROR - {e}') print('All checks done.') " fi - name: Upload coverage report uses: codecov/codecov-action@v4 if: hashFiles('coverage.xml') != '' with: file: ./coverage.xml fail_ci_if_error: false # ---------------------------------------------------------- # Job 3: Docker Build # ---------------------------------------------------------- docker-build: name: Docker Build & Push runs-on: ubuntu-latest needs: test permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - name: Build (and push on non-PR) uses: docker/build-push-action@v6 with: context: . target: runner push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - name: Verify image size if: github.event_name != 'pull_request' run: | SIZE=$(docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest --format='{{.Size}}' 2>/dev/null || echo "0") SIZE_MB=$((SIZE / 1024 / 1024)) echo "Image size: ${SIZE_MB}MB" if [ "$SIZE_MB" -gt 500 ]; then echo "::warning::Image size ${SIZE_MB}MB exceeds 500MB limit" fi # ---------------------------------------------------------- # Job 4: Notify on failure # ---------------------------------------------------------- notify-failure: name: Notify on Failure runs-on: ubuntu-latest needs: [lint, test, docker-build] if: failure() && github.event_name == 'push' steps: - name: Notify Telegram if: vars.TELEGRAM_BOT_TOKEN != '' run: | curl -s -X POST \ "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \ -d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \ -d text="❌ CI FAILED: ${{ github.repository }} branch=${{ github.ref_name }} commit=${{ github.sha }}" \ -d parse_mode="Markdown" || true - name: Notify Slack if: vars.SLACK_WEBHOOK_URL != '' run: | curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \ -H 'Content-type: application/json' \ --data "{\"text\":\"❌ CI FAILED: \`${{ github.repository }}\` branch=\`${{ github.ref_name }}\` commit=\`${{ github.sha }}\`\"}" || true