ci.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. name: CI
  2. on:
  3. push:
  4. branches: [main]
  5. pull_request:
  6. branches: [main]
  7. workflow_dispatch:
  8. # Run on PRs targeting main, but skip for repo owner (runs local tests)
  9. # Skip CI for PRs authored by repo owner (they run tests locally)
  10. # Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI
  11. env:
  12. PYTHON_VERSION: '3.11'
  13. NODE_VERSION: '22'
  14. # Cancel in-progress runs for the same branch
  15. concurrency:
  16. group: ${{ github.workflow }}-${{ github.ref }}
  17. cancel-in-progress: true
  18. # Minimum permissions for all jobs
  19. permissions:
  20. contents: read
  21. jobs:
  22. # ============================================================================
  23. # Backend Checks
  24. # ============================================================================
  25. backend-lint:
  26. name: Backend Lint
  27. runs-on: ubuntu-latest
  28. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  29. steps:
  30. - uses: actions/checkout@v4
  31. - name: Set up Python
  32. uses: actions/setup-python@v5
  33. with:
  34. python-version: ${{ env.PYTHON_VERSION }}
  35. - name: Install ruff
  36. run: pip install ruff
  37. - name: Run ruff check
  38. run: ruff check backend/
  39. - name: Run ruff format check
  40. run: ruff format --check backend/
  41. backend-security:
  42. name: Backend Security
  43. runs-on: ubuntu-latest
  44. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  45. continue-on-error: true
  46. steps:
  47. - uses: actions/checkout@v4
  48. - name: Set up Python
  49. uses: actions/setup-python@v5
  50. with:
  51. python-version: ${{ env.PYTHON_VERSION }}
  52. - name: Install dependencies
  53. run: |
  54. python -m pip install --upgrade pip
  55. pip install -r requirements.txt
  56. pip install pip-audit
  57. - name: Run pip-audit
  58. run: |
  59. # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).
  60. # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.
  61. pip-audit --desc on --ignore-vuln CVE-2026-4539
  62. backend-tests:
  63. name: Backend Tests
  64. runs-on: ubuntu-latest
  65. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  66. needs: backend-lint
  67. steps:
  68. - uses: actions/checkout@v4
  69. - name: Set up Python
  70. uses: actions/setup-python@v5
  71. with:
  72. python-version: ${{ env.PYTHON_VERSION }}
  73. - name: Cache pip
  74. uses: actions/cache@v4
  75. with:
  76. path: ~/.cache/pip
  77. key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
  78. restore-keys: |
  79. ${{ runner.os }}-pip-
  80. - name: Install dependencies
  81. run: |
  82. python -m pip install --upgrade pip
  83. pip install -r requirements.txt
  84. pip install -r requirements-dev.txt
  85. - name: Run tests
  86. timeout-minutes: 10
  87. run: |
  88. cd backend
  89. python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto
  90. # ============================================================================
  91. # Frontend Checks
  92. # ============================================================================
  93. frontend-lint:
  94. name: Frontend Lint
  95. runs-on: ubuntu-latest
  96. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  97. steps:
  98. - uses: actions/checkout@v4
  99. - name: Set up Node.js
  100. uses: actions/setup-node@v4
  101. with:
  102. node-version: ${{ env.NODE_VERSION }}
  103. cache: 'npm'
  104. cache-dependency-path: frontend/package-lock.json
  105. - name: Install dependencies
  106. working-directory: frontend
  107. run: npm ci
  108. - name: Run ESLint
  109. working-directory: frontend
  110. run: npm run lint
  111. frontend-security:
  112. name: Frontend Security
  113. runs-on: ubuntu-latest
  114. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  115. continue-on-error: true
  116. steps:
  117. - uses: actions/checkout@v4
  118. - name: Set up Node.js
  119. uses: actions/setup-node@v4
  120. with:
  121. node-version: ${{ env.NODE_VERSION }}
  122. cache: 'npm'
  123. cache-dependency-path: frontend/package-lock.json
  124. - name: Install dependencies
  125. working-directory: frontend
  126. run: npm ci
  127. - name: Run npm audit
  128. working-directory: frontend
  129. run: |
  130. # Only audit production dependencies and filter out npm-internal packages.
  131. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  132. # so we parse package-lock.json directly to get the real prod dep list.
  133. npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
  134. python3 -c "
  135. import json, sys
  136. data = json.load(open('/tmp/audit.json'))
  137. lock = json.load(open('package-lock.json'))
  138. prod = set()
  139. for path, info in lock.get('packages', {}).items():
  140. if path and not info.get('dev') and not info.get('devOptional'):
  141. prod.add(path.split('node_modules/')[-1])
  142. vulns = data.get('vulnerabilities', {})
  143. fixable = {n: v for n, v in vulns.items()
  144. if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
  145. skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
  146. if fixable:
  147. for name, v in fixable.items():
  148. print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
  149. sys.exit(1)
  150. total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
  151. print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
  152. "
  153. frontend-typecheck:
  154. name: Frontend Type Check
  155. runs-on: ubuntu-latest
  156. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  157. steps:
  158. - uses: actions/checkout@v4
  159. - name: Set up Node.js
  160. uses: actions/setup-node@v4
  161. with:
  162. node-version: ${{ env.NODE_VERSION }}
  163. cache: 'npm'
  164. cache-dependency-path: frontend/package-lock.json
  165. - name: Install dependencies
  166. working-directory: frontend
  167. run: npm ci
  168. - name: Run TypeScript check
  169. working-directory: frontend
  170. run: npx tsc --noEmit
  171. frontend-tests:
  172. name: Frontend Tests
  173. runs-on: ubuntu-latest
  174. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  175. needs: [frontend-lint, frontend-typecheck]
  176. steps:
  177. - uses: actions/checkout@v4
  178. - name: Set up Node.js
  179. uses: actions/setup-node@v4
  180. with:
  181. node-version: ${{ env.NODE_VERSION }}
  182. cache: 'npm'
  183. cache-dependency-path: frontend/package-lock.json
  184. - name: Install dependencies
  185. working-directory: frontend
  186. run: npm ci
  187. - name: Run tests
  188. timeout-minutes: 10
  189. working-directory: frontend
  190. run: npm run test:run
  191. frontend-build:
  192. name: Frontend Build
  193. runs-on: ubuntu-latest
  194. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  195. needs: [frontend-tests]
  196. steps:
  197. - uses: actions/checkout@v4
  198. - name: Set up Node.js
  199. uses: actions/setup-node@v4
  200. with:
  201. node-version: ${{ env.NODE_VERSION }}
  202. cache: 'npm'
  203. cache-dependency-path: frontend/package-lock.json
  204. - name: Install dependencies
  205. working-directory: frontend
  206. run: npm ci
  207. - name: Build
  208. working-directory: frontend
  209. run: npm run build
  210. # ============================================================================
  211. # Docker Tests (matches test_docker.sh)
  212. # ============================================================================
  213. docker-test:
  214. name: Docker Build
  215. runs-on: ubuntu-latest
  216. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  217. timeout-minutes: 20
  218. needs: [backend-tests, frontend-build]
  219. steps:
  220. - uses: actions/checkout@v4
  221. # Test 1: Docker Build
  222. - name: Build production image
  223. run: docker build -t bambuddy:test .
  224. - name: Verify backend imports
  225. run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
  226. - name: Verify static files exist
  227. run: docker run --rm bambuddy:test test -d /app/static
  228. # Test 2: Backend Unit Tests in Docker
  229. - name: Build backend test image
  230. run: docker compose -f docker-compose.test.yml build backend-test
  231. - name: Run backend tests in Docker
  232. run: docker compose -f docker-compose.test.yml run --rm backend-test
  233. # Test 3: Frontend Unit Tests in Docker
  234. - name: Build frontend test image
  235. run: docker compose -f docker-compose.test.yml build frontend-test
  236. - name: Run frontend tests in Docker
  237. run: docker compose -f docker-compose.test.yml run --rm frontend-test
  238. # Test 4: Integration Tests
  239. - name: Build integration container
  240. run: docker compose -f docker-compose.test.yml build integration
  241. - name: Start integration container
  242. run: |
  243. docker compose -f docker-compose.test.yml up -d integration
  244. echo "Waiting for container to be healthy..."
  245. for i in {1..30}; do
  246. if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
  247. echo "Container is healthy"
  248. break
  249. fi
  250. sleep 2
  251. done
  252. - name: Test health endpoint
  253. run: |
  254. HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
  255. echo "$HEALTH"
  256. echo "$HEALTH" | grep -q "healthy"
  257. - name: Test API endpoint
  258. run: |
  259. docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
  260. - name: Test static files served
  261. run: |
  262. STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
  263. echo "Static files HTTP status: $STATUS"
  264. [ "$STATUS" = "200" ]
  265. # Test 5: Integration Test Suite (pytest)
  266. - name: Build integration test runner
  267. run: docker compose -f docker-compose.test.yml build integration-test-runner
  268. - name: Run integration test suite
  269. run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
  270. - name: Show logs on failure
  271. if: failure()
  272. run: docker compose -f docker-compose.test.yml logs
  273. - name: Cleanup
  274. if: always()
  275. run: docker compose -f docker-compose.test.yml down -v --remove-orphans