| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359 |
- name: CI
- on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
- workflow_dispatch:
- # Run on PRs targeting main, but skip for repo owner (runs local tests)
- # Skip CI for PRs authored by repo owner (they run tests locally)
- # Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI
- env:
- PYTHON_VERSION: '3.11'
- NODE_VERSION: '22'
- # Cancel in-progress runs for the same branch
- concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
- # Minimum permissions for all jobs
- permissions:
- contents: read
- jobs:
- # ============================================================================
- # Backend Checks
- # ============================================================================
- backend-lint:
- name: Backend Lint
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ env.PYTHON_VERSION }}
- - name: Install ruff
- run: pip install ruff
- - name: Run ruff check
- run: ruff check backend/
- - name: Run ruff format check
- run: ruff format --check backend/
- backend-security:
- name: Backend Security
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- continue-on-error: true
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ env.PYTHON_VERSION }}
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
- pip install pip-audit
- - name: Run pip-audit
- run: |
- # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).
- # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.
- #
- # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
- # Advisory says "key length is chosen by the application that uses the library" — no
- # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
- # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
- # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
- pip-audit --desc on \
- --ignore-vuln CVE-2026-4539 \
- --ignore-vuln CVE-2025-45768
- backend-tests:
- name: Backend Tests (shard ${{ matrix.shard }}/4)
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- needs: backend-lint
- strategy:
- # Don't cancel sibling shards if one fails — we want every shard's
- # failure list, not just the first one, so a single PR push shows
- # all broken tests in one go.
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4]
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ env.PYTHON_VERSION }}
- - name: Cache pip
- uses: actions/cache@v4
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- restore-keys: |
- ${{ runner.os }}-pip-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
- pip install -r requirements-dev.txt
- - name: Run tests (shard ${{ matrix.shard }}/4)
- timeout-minutes: 10
- run: |
- cd backend
- # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
- # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
- # --splits 4 --group N uses pytest-split to slice the collected
- # test set roughly evenly across the 4 matrix shards; first run
- # is name-hash-based, subsequent runs improve via .test_durations
- # if you ever commit one (we don't — even the naive hash split
- # gets us ≈25% per shard given the test mix here).
- python -m pytest tests/ \
- --tb=short \
- --timeout=60 --timeout-method=thread \
- -n auto \
- --splits 4 --group ${{ matrix.shard }}
- # ============================================================================
- # Frontend Checks
- # ============================================================================
- frontend-lint:
- name: Frontend Lint
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- steps:
- - uses: actions/checkout@v4
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
- - name: Install dependencies
- working-directory: frontend
- run: npm ci
- - name: Run ESLint
- working-directory: frontend
- run: npm run lint
- frontend-security:
- name: Frontend Security
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- continue-on-error: true
- steps:
- - uses: actions/checkout@v4
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
- - name: Install dependencies
- working-directory: frontend
- run: npm ci
- - name: Run npm audit
- working-directory: frontend
- run: |
- # Only audit production dependencies and filter out npm-internal packages.
- # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
- # so we parse package-lock.json directly to get the real prod dep list.
- npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
- python3 -c "
- import json, sys
- data = json.load(open('/tmp/audit.json'))
- lock = json.load(open('package-lock.json'))
- prod = set()
- for path, info in lock.get('packages', {}).items():
- if path and not info.get('dev') and not info.get('devOptional'):
- prod.add(path.split('node_modules/')[-1])
- vulns = data.get('vulnerabilities', {})
- fixable = {n: v for n, v in vulns.items()
- if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
- skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
- if fixable:
- for name, v in fixable.items():
- print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
- sys.exit(1)
- total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
- print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
- "
- frontend-typecheck:
- name: Frontend Type Check
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- steps:
- - uses: actions/checkout@v4
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
- - name: Install dependencies
- working-directory: frontend
- run: npm ci
- - name: Run TypeScript check
- working-directory: frontend
- run: npx tsc --noEmit
- frontend-tests:
- name: Frontend Tests
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- needs: [frontend-lint, frontend-typecheck]
- steps:
- - uses: actions/checkout@v4
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
- - name: Install dependencies
- working-directory: frontend
- run: npm ci
- - name: Run tests
- timeout-minutes: 10
- working-directory: frontend
- run: npm run test:run
- frontend-build:
- name: Frontend Build
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- needs: [frontend-tests]
- steps:
- - uses: actions/checkout@v4
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
- - name: Install dependencies
- working-directory: frontend
- run: npm ci
- - name: Build
- working-directory: frontend
- run: npm run build
- # ============================================================================
- # Docker Tests (matches test_docker.sh)
- # ============================================================================
- docker-test:
- name: Docker Build
- runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
- timeout-minutes: 20
- needs: [backend-tests, frontend-build]
- steps:
- - uses: actions/checkout@v4
- # Test 1: Docker Build
- - name: Build production image
- run: docker build -t bambuddy:test .
- - name: Verify backend imports
- run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
- - name: Verify static files exist
- run: docker run --rm bambuddy:test test -d /app/static
- # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit
- # tests inside the test image) used to run here. They've been removed
- # from the CI pipeline because the host-side `backend-tests` and
- # `frontend-tests` jobs already exercise the exact same test code
- # against the exact same Python version + requirements.txt the test
- # image installs — on 2-vCPU GHA runners a re-run inside Docker
- # added 5-10 min of wall-clock for zero new coverage. The
- # image-validation purpose of this job (does it build, do imports
- # resolve, does the integration container come up and answer HTTP)
- # lives in the surrounding steps. test_docker.sh keeps the unit-test
- # runs because devs running it locally don't have a separate host-
- # side pytest job to compare against.
- # Test 4: Integration Tests
- - name: Build integration container
- run: docker compose -f docker-compose.test.yml build integration
- - name: Start integration container
- run: |
- docker compose -f docker-compose.test.yml up -d integration
- echo "Waiting for container to be healthy..."
- for i in {1..30}; do
- if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
- echo "Container is healthy"
- break
- fi
- sleep 2
- done
- - name: Test health endpoint
- run: |
- HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
- echo "$HEALTH"
- echo "$HEALTH" | grep -q "healthy"
- - name: Test API endpoint
- run: |
- docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
- - name: Test static files served
- run: |
- STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
- echo "Static files HTTP status: $STATUS"
- [ "$STATUS" = "200" ]
- # Test 5: Integration Test Suite (pytest)
- - name: Build integration test runner
- run: docker compose -f docker-compose.test.yml build integration-test-runner
- - name: Run integration test suite
- run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
- - name: Show logs on failure
- if: failure()
- run: docker compose -f docker-compose.test.yml logs
- - name: Cleanup
- if: always()
- run: docker compose -f docker-compose.test.yml down -v --remove-orphans
|