ci.yml 11 KB

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