ci.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. #
  62. # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
  63. # Advisory says "key length is chosen by the application that uses the library" — no
  64. # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
  65. # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
  66. # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
  67. pip-audit --desc on \
  68. --ignore-vuln CVE-2026-4539 \
  69. --ignore-vuln CVE-2025-45768
  70. backend-tests:
  71. name: Backend Tests
  72. runs-on: ubuntu-latest
  73. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  74. needs: backend-lint
  75. steps:
  76. - uses: actions/checkout@v4
  77. - name: Set up Python
  78. uses: actions/setup-python@v5
  79. with:
  80. python-version: ${{ env.PYTHON_VERSION }}
  81. - name: Cache pip
  82. uses: actions/cache@v4
  83. with:
  84. path: ~/.cache/pip
  85. key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
  86. restore-keys: |
  87. ${{ runner.os }}-pip-
  88. - name: Install dependencies
  89. run: |
  90. python -m pip install --upgrade pip
  91. pip install -r requirements.txt
  92. pip install -r requirements-dev.txt
  93. - name: Run tests
  94. timeout-minutes: 10
  95. run: |
  96. cd backend
  97. python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto
  98. # ============================================================================
  99. # Frontend Checks
  100. # ============================================================================
  101. frontend-lint:
  102. name: Frontend Lint
  103. runs-on: ubuntu-latest
  104. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  105. steps:
  106. - uses: actions/checkout@v4
  107. - name: Set up Node.js
  108. uses: actions/setup-node@v4
  109. with:
  110. node-version: ${{ env.NODE_VERSION }}
  111. cache: 'npm'
  112. cache-dependency-path: frontend/package-lock.json
  113. - name: Install dependencies
  114. working-directory: frontend
  115. run: npm ci
  116. - name: Run ESLint
  117. working-directory: frontend
  118. run: npm run lint
  119. frontend-security:
  120. name: Frontend Security
  121. runs-on: ubuntu-latest
  122. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  123. continue-on-error: true
  124. steps:
  125. - uses: actions/checkout@v4
  126. - name: Set up Node.js
  127. uses: actions/setup-node@v4
  128. with:
  129. node-version: ${{ env.NODE_VERSION }}
  130. cache: 'npm'
  131. cache-dependency-path: frontend/package-lock.json
  132. - name: Install dependencies
  133. working-directory: frontend
  134. run: npm ci
  135. - name: Run npm audit
  136. working-directory: frontend
  137. run: |
  138. # Only audit production dependencies and filter out npm-internal packages.
  139. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  140. # so we parse package-lock.json directly to get the real prod dep list.
  141. npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
  142. python3 -c "
  143. import json, sys
  144. data = json.load(open('/tmp/audit.json'))
  145. lock = json.load(open('package-lock.json'))
  146. prod = set()
  147. for path, info in lock.get('packages', {}).items():
  148. if path and not info.get('dev') and not info.get('devOptional'):
  149. prod.add(path.split('node_modules/')[-1])
  150. vulns = data.get('vulnerabilities', {})
  151. fixable = {n: v for n, v in vulns.items()
  152. if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
  153. skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
  154. if fixable:
  155. for name, v in fixable.items():
  156. print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
  157. sys.exit(1)
  158. total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
  159. print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
  160. "
  161. frontend-typecheck:
  162. name: Frontend Type Check
  163. runs-on: ubuntu-latest
  164. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  165. steps:
  166. - uses: actions/checkout@v4
  167. - name: Set up Node.js
  168. uses: actions/setup-node@v4
  169. with:
  170. node-version: ${{ env.NODE_VERSION }}
  171. cache: 'npm'
  172. cache-dependency-path: frontend/package-lock.json
  173. - name: Install dependencies
  174. working-directory: frontend
  175. run: npm ci
  176. - name: Run TypeScript check
  177. working-directory: frontend
  178. run: npx tsc --noEmit
  179. frontend-tests:
  180. name: Frontend Tests
  181. runs-on: ubuntu-latest
  182. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  183. needs: [frontend-lint, frontend-typecheck]
  184. steps:
  185. - uses: actions/checkout@v4
  186. - name: Set up Node.js
  187. uses: actions/setup-node@v4
  188. with:
  189. node-version: ${{ env.NODE_VERSION }}
  190. cache: 'npm'
  191. cache-dependency-path: frontend/package-lock.json
  192. - name: Install dependencies
  193. working-directory: frontend
  194. run: npm ci
  195. - name: Run tests
  196. timeout-minutes: 10
  197. working-directory: frontend
  198. run: npm run test:run
  199. frontend-build:
  200. name: Frontend Build
  201. runs-on: ubuntu-latest
  202. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  203. needs: [frontend-tests]
  204. steps:
  205. - uses: actions/checkout@v4
  206. - name: Set up Node.js
  207. uses: actions/setup-node@v4
  208. with:
  209. node-version: ${{ env.NODE_VERSION }}
  210. cache: 'npm'
  211. cache-dependency-path: frontend/package-lock.json
  212. - name: Install dependencies
  213. working-directory: frontend
  214. run: npm ci
  215. - name: Build
  216. working-directory: frontend
  217. run: npm run build
  218. # ============================================================================
  219. # Docker Tests (matches test_docker.sh)
  220. # ============================================================================
  221. docker-test:
  222. name: Docker Build
  223. runs-on: ubuntu-latest
  224. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  225. timeout-minutes: 20
  226. needs: [backend-tests, frontend-build]
  227. steps:
  228. - uses: actions/checkout@v4
  229. # Test 1: Docker Build
  230. - name: Build production image
  231. run: docker build -t bambuddy:test .
  232. - name: Verify backend imports
  233. run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
  234. - name: Verify static files exist
  235. run: docker run --rm bambuddy:test test -d /app/static
  236. # Test 2: Backend Unit Tests in Docker
  237. - name: Build backend test image
  238. run: docker compose -f docker-compose.test.yml build backend-test
  239. - name: Run backend tests in Docker
  240. run: docker compose -f docker-compose.test.yml run --rm backend-test
  241. # Test 3: Frontend Unit Tests in Docker
  242. - name: Build frontend test image
  243. run: docker compose -f docker-compose.test.yml build frontend-test
  244. - name: Run frontend tests in Docker
  245. run: docker compose -f docker-compose.test.yml run --rm frontend-test
  246. # Test 4: Integration Tests
  247. - name: Build integration container
  248. run: docker compose -f docker-compose.test.yml build integration
  249. - name: Start integration container
  250. run: |
  251. docker compose -f docker-compose.test.yml up -d integration
  252. echo "Waiting for container to be healthy..."
  253. for i in {1..30}; do
  254. if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
  255. echo "Container is healthy"
  256. break
  257. fi
  258. sleep 2
  259. done
  260. - name: Test health endpoint
  261. run: |
  262. HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
  263. echo "$HEALTH"
  264. echo "$HEALTH" | grep -q "healthy"
  265. - name: Test API endpoint
  266. run: |
  267. docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
  268. - name: Test static files served
  269. run: |
  270. STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
  271. echo "Static files HTTP status: $STATUS"
  272. [ "$STATUS" = "200" ]
  273. # Test 5: Integration Test Suite (pytest)
  274. - name: Build integration test runner
  275. run: docker compose -f docker-compose.test.yml build integration-test-runner
  276. - name: Run integration test suite
  277. run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
  278. - name: Show logs on failure
  279. if: failure()
  280. run: docker compose -f docker-compose.test.yml logs
  281. - name: Cleanup
  282. if: always()
  283. run: docker compose -f docker-compose.test.yml down -v --remove-orphans