ci.yml 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 (shard ${{ matrix.shard }}/4)
  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. strategy:
  76. # Don't cancel sibling shards if one fails — we want every shard's
  77. # failure list, not just the first one, so a single PR push shows
  78. # all broken tests in one go.
  79. fail-fast: false
  80. matrix:
  81. shard: [1, 2, 3, 4]
  82. steps:
  83. - uses: actions/checkout@v4
  84. - name: Set up Python
  85. uses: actions/setup-python@v5
  86. with:
  87. python-version: ${{ env.PYTHON_VERSION }}
  88. - name: Cache pip
  89. uses: actions/cache@v4
  90. with:
  91. path: ~/.cache/pip
  92. key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
  93. restore-keys: |
  94. ${{ runner.os }}-pip-
  95. - name: Install dependencies
  96. run: |
  97. python -m pip install --upgrade pip
  98. pip install -r requirements.txt
  99. pip install -r requirements-dev.txt
  100. - name: Run tests (shard ${{ matrix.shard }}/4)
  101. timeout-minutes: 10
  102. run: |
  103. cd backend
  104. # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
  105. # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
  106. # --splits 4 --group N uses pytest-split to slice the collected
  107. # test set roughly evenly across the 4 matrix shards; first run
  108. # is name-hash-based, subsequent runs improve via .test_durations
  109. # if you ever commit one (we don't — even the naive hash split
  110. # gets us ≈25% per shard given the test mix here).
  111. python -m pytest tests/ \
  112. --tb=short \
  113. --timeout=60 --timeout-method=thread \
  114. -n auto \
  115. --splits 4 --group ${{ matrix.shard }}
  116. # ============================================================================
  117. # Frontend Checks
  118. # ============================================================================
  119. frontend-lint:
  120. name: Frontend Lint
  121. runs-on: ubuntu-latest
  122. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  123. steps:
  124. - uses: actions/checkout@v4
  125. - name: Set up Node.js
  126. uses: actions/setup-node@v4
  127. with:
  128. node-version: ${{ env.NODE_VERSION }}
  129. cache: 'npm'
  130. cache-dependency-path: frontend/package-lock.json
  131. - name: Install dependencies
  132. working-directory: frontend
  133. run: npm ci
  134. - name: Run ESLint
  135. working-directory: frontend
  136. run: npm run lint
  137. frontend-security:
  138. name: Frontend Security
  139. runs-on: ubuntu-latest
  140. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  141. continue-on-error: true
  142. steps:
  143. - uses: actions/checkout@v4
  144. - name: Set up Node.js
  145. uses: actions/setup-node@v4
  146. with:
  147. node-version: ${{ env.NODE_VERSION }}
  148. cache: 'npm'
  149. cache-dependency-path: frontend/package-lock.json
  150. - name: Install dependencies
  151. working-directory: frontend
  152. run: npm ci
  153. - name: Run npm audit
  154. working-directory: frontend
  155. run: |
  156. # Only audit production dependencies and filter out npm-internal packages.
  157. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  158. # so we parse package-lock.json directly to get the real prod dep list.
  159. npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
  160. python3 -c "
  161. import json, sys
  162. data = json.load(open('/tmp/audit.json'))
  163. lock = json.load(open('package-lock.json'))
  164. prod = set()
  165. for path, info in lock.get('packages', {}).items():
  166. if path and not info.get('dev') and not info.get('devOptional'):
  167. prod.add(path.split('node_modules/')[-1])
  168. vulns = data.get('vulnerabilities', {})
  169. fixable = {n: v for n, v in vulns.items()
  170. if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
  171. skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
  172. if fixable:
  173. for name, v in fixable.items():
  174. print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
  175. sys.exit(1)
  176. total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
  177. print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
  178. "
  179. frontend-typecheck:
  180. name: Frontend Type Check
  181. runs-on: ubuntu-latest
  182. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  183. steps:
  184. - uses: actions/checkout@v4
  185. - name: Set up Node.js
  186. uses: actions/setup-node@v4
  187. with:
  188. node-version: ${{ env.NODE_VERSION }}
  189. cache: 'npm'
  190. cache-dependency-path: frontend/package-lock.json
  191. - name: Install dependencies
  192. working-directory: frontend
  193. run: npm ci
  194. - name: Run TypeScript check
  195. working-directory: frontend
  196. run: npx tsc --noEmit
  197. frontend-tests:
  198. name: Frontend Tests
  199. runs-on: ubuntu-latest
  200. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  201. needs: [frontend-lint, frontend-typecheck]
  202. steps:
  203. - uses: actions/checkout@v4
  204. - name: Set up Node.js
  205. uses: actions/setup-node@v4
  206. with:
  207. node-version: ${{ env.NODE_VERSION }}
  208. cache: 'npm'
  209. cache-dependency-path: frontend/package-lock.json
  210. - name: Install dependencies
  211. working-directory: frontend
  212. run: npm ci
  213. - name: Run tests
  214. timeout-minutes: 10
  215. working-directory: frontend
  216. run: npm run test:run
  217. frontend-build:
  218. name: Frontend Build
  219. runs-on: ubuntu-latest
  220. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  221. needs: [frontend-tests]
  222. steps:
  223. - uses: actions/checkout@v4
  224. - name: Set up Node.js
  225. uses: actions/setup-node@v4
  226. with:
  227. node-version: ${{ env.NODE_VERSION }}
  228. cache: 'npm'
  229. cache-dependency-path: frontend/package-lock.json
  230. - name: Install dependencies
  231. working-directory: frontend
  232. run: npm ci
  233. - name: Build
  234. working-directory: frontend
  235. run: npm run build
  236. # ============================================================================
  237. # Docker Tests (matches test_docker.sh)
  238. # ============================================================================
  239. docker-test:
  240. name: Docker Build
  241. runs-on: ubuntu-latest
  242. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  243. timeout-minutes: 20
  244. needs: [backend-tests, frontend-build]
  245. steps:
  246. - uses: actions/checkout@v4
  247. # Test 1: Docker Build
  248. - name: Build production image
  249. run: docker build -t bambuddy:test .
  250. - name: Verify backend imports
  251. run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
  252. - name: Verify static files exist
  253. run: docker run --rm bambuddy:test test -d /app/static
  254. # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit
  255. # tests inside the test image) used to run here. They've been removed
  256. # from the CI pipeline because the host-side `backend-tests` and
  257. # `frontend-tests` jobs already exercise the exact same test code
  258. # against the exact same Python version + requirements.txt the test
  259. # image installs — on 2-vCPU GHA runners a re-run inside Docker
  260. # added 5-10 min of wall-clock for zero new coverage. The
  261. # image-validation purpose of this job (does it build, do imports
  262. # resolve, does the integration container come up and answer HTTP)
  263. # lives in the surrounding steps. test_docker.sh keeps the unit-test
  264. # runs because devs running it locally don't have a separate host-
  265. # side pytest job to compare against.
  266. # Test 4: Integration Tests
  267. - name: Build integration container
  268. run: docker compose -f docker-compose.test.yml build integration
  269. - name: Start integration container
  270. run: |
  271. docker compose -f docker-compose.test.yml up -d integration
  272. echo "Waiting for container to be healthy..."
  273. for i in {1..30}; do
  274. if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
  275. echo "Container is healthy"
  276. break
  277. fi
  278. sleep 2
  279. done
  280. - name: Test health endpoint
  281. run: |
  282. HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
  283. echo "$HEALTH"
  284. echo "$HEALTH" | grep -q "healthy"
  285. - name: Test API endpoint
  286. run: |
  287. docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
  288. - name: Test static files served
  289. run: |
  290. STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
  291. echo "Static files HTTP status: $STATUS"
  292. [ "$STATUS" = "200" ]
  293. # Test 5: Integration Test Suite (pytest)
  294. - name: Build integration test runner
  295. run: docker compose -f docker-compose.test.yml build integration-test-runner
  296. - name: Run integration test suite
  297. run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
  298. - name: Show logs on failure
  299. if: failure()
  300. run: docker compose -f docker-compose.test.yml logs
  301. - name: Cleanup
  302. if: always()
  303. run: docker compose -f docker-compose.test.yml down -v --remove-orphans