ci.yml 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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@v6
  31. - name: Set up Python
  32. uses: actions/setup-python@v6
  33. with:
  34. python-version: ${{ env.PYTHON_VERSION }}
  35. - name: Install ruff
  36. # Install the exact pin from requirements-dev.txt rather than the latest
  37. # release, so CI and contributors run the same linter. `pip install ruff`
  38. # silently drifted ahead of every local venv.
  39. run: pip install "$(grep -E '^ruff==' requirements-dev.txt)"
  40. - name: Run ruff check
  41. run: ruff check backend/
  42. - name: Run ruff format check
  43. run: ruff format --check backend/
  44. backend-security:
  45. name: Backend Security
  46. runs-on: ubuntu-latest
  47. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  48. continue-on-error: true
  49. steps:
  50. - uses: actions/checkout@v6
  51. - name: Set up Python
  52. uses: actions/setup-python@v6
  53. with:
  54. python-version: ${{ env.PYTHON_VERSION }}
  55. - name: Install dependencies
  56. run: |
  57. # Upgrade setuptools too: the runner's Python toolcache ships an old
  58. # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
  59. # A fix exists, so we upgrade rather than --ignore-vuln.
  60. python -m pip install --upgrade pip setuptools
  61. pip install -r requirements.txt
  62. pip install pip-audit
  63. - name: Run pip-audit
  64. run: |
  65. # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).
  66. # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.
  67. #
  68. # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
  69. # Advisory says "key length is chosen by the application that uses the library" — no
  70. # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
  71. # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
  72. # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
  73. pip-audit --desc on \
  74. --ignore-vuln CVE-2026-4539 \
  75. --ignore-vuln CVE-2025-45768
  76. backend-tests:
  77. name: Backend Tests (shard ${{ matrix.shard }}/4)
  78. runs-on: ubuntu-latest
  79. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  80. needs: backend-lint
  81. strategy:
  82. # Don't cancel sibling shards if one fails — we want every shard's
  83. # failure list, not just the first one, so a single PR push shows
  84. # all broken tests in one go.
  85. fail-fast: false
  86. matrix:
  87. shard: [1, 2, 3, 4]
  88. steps:
  89. - uses: actions/checkout@v6
  90. - name: Set up Python
  91. uses: actions/setup-python@v6
  92. with:
  93. python-version: ${{ env.PYTHON_VERSION }}
  94. - name: Cache pip
  95. uses: actions/cache@v5
  96. with:
  97. path: ~/.cache/pip
  98. key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
  99. restore-keys: |
  100. ${{ runner.os }}-pip-
  101. - name: Install dependencies
  102. run: |
  103. python -m pip install --upgrade pip
  104. pip install -r requirements.txt
  105. pip install -r requirements-dev.txt
  106. - name: Run tests (shard ${{ matrix.shard }}/4)
  107. timeout-minutes: 10
  108. run: |
  109. cd backend
  110. # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
  111. # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
  112. # --splits 4 --group N uses pytest-split to slice the collected
  113. # test set roughly evenly across the 4 matrix shards; first run
  114. # is name-hash-based, subsequent runs improve via .test_durations
  115. # if you ever commit one (we don't — even the naive hash split
  116. # gets us ≈25% per shard given the test mix here).
  117. python -m pytest tests/ \
  118. --tb=short \
  119. --timeout=60 --timeout-method=thread \
  120. -n auto \
  121. --splits 4 --group ${{ matrix.shard }}
  122. # ============================================================================
  123. # Frontend Checks
  124. # ============================================================================
  125. frontend-lint:
  126. name: Frontend Lint
  127. runs-on: ubuntu-latest
  128. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  129. steps:
  130. - uses: actions/checkout@v6
  131. - name: Set up Node.js
  132. uses: actions/setup-node@v6
  133. with:
  134. node-version: ${{ env.NODE_VERSION }}
  135. cache: 'npm'
  136. cache-dependency-path: frontend/package-lock.json
  137. - name: Install dependencies
  138. working-directory: frontend
  139. run: npm ci
  140. - name: Run ESLint
  141. working-directory: frontend
  142. run: npm run lint
  143. frontend-security:
  144. name: Frontend Security
  145. runs-on: ubuntu-latest
  146. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  147. continue-on-error: true
  148. steps:
  149. - uses: actions/checkout@v6
  150. - name: Set up Node.js
  151. uses: actions/setup-node@v6
  152. with:
  153. node-version: ${{ env.NODE_VERSION }}
  154. cache: 'npm'
  155. cache-dependency-path: frontend/package-lock.json
  156. - name: Install dependencies
  157. working-directory: frontend
  158. run: npm ci
  159. - name: Run npm audit
  160. working-directory: frontend
  161. run: |
  162. # Only audit production dependencies and filter out npm-internal packages.
  163. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  164. # so we parse package-lock.json directly to get the real prod dep list.
  165. npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
  166. python3 -c "
  167. import json, sys
  168. data = json.load(open('/tmp/audit.json'))
  169. lock = json.load(open('package-lock.json'))
  170. prod = set()
  171. for path, info in lock.get('packages', {}).items():
  172. if path and not info.get('dev') and not info.get('devOptional'):
  173. prod.add(path.split('node_modules/')[-1])
  174. vulns = data.get('vulnerabilities', {})
  175. # Documented advisory exceptions: high/critical findings whose only offered
  176. # 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
  177. # Keyed by GHSA id; RE-REVIEW ON EVERY react-router BUMP.
  178. # GHSA-qwww-vcr4-c8h2 - React Router RSC-mode CSRF. Bambuddy is a Vite SPA
  179. # using BrowserRouter with no RSC runtime (@react-router/server is NOT
  180. # installed), so the vulnerable code path is unreachable. No non-major fix
  181. # exists (7.18.1 is the most-patched 7.x - it clears 14 other advisories that
  182. # older 7.x carry - and the RSC fix landed only in the 8.3.0 major). react-router
  183. # /-dom are pinned to 7.18.1 in package.json. If a non-major fix ships, this stops
  184. # being exempt (major-only guard below) and the gate fails until we take it.
  185. ALLOWLIST = {'GHSA-qwww-vcr4-c8h2'}
  186. def advisory_ids(name, seen=None):
  187. seen = seen if seen is not None else set()
  188. if name in seen:
  189. return set()
  190. seen.add(name)
  191. ids = set()
  192. for item in vulns.get(name, {}).get('via', []):
  193. if isinstance(item, dict):
  194. url = item.get('url', '')
  195. if '/advisories/' in url:
  196. ids.add(url.rsplit('/', 1)[-1])
  197. elif isinstance(item, str):
  198. ids |= advisory_ids(item, seen)
  199. return ids
  200. def fix_is_major(v):
  201. fa = v.get('fixAvailable')
  202. return isinstance(fa, dict) and fa.get('isSemVerMajor')
  203. def exempt(name, v):
  204. ids = advisory_ids(name)
  205. return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
  206. fixable = {n: v for n, v in vulns.items()
  207. if n in prod and v.get('severity') in ('high', 'critical')
  208. and v.get('fixAvailable') and not exempt(n, v)}
  209. skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
  210. if fixable:
  211. for name, v in fixable.items():
  212. print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
  213. sys.exit(1)
  214. total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
  215. exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
  216. print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
  217. if exempted:
  218. print('exempted (documented, unreachable): ' + ', '.join(exempted))
  219. "
  220. frontend-typecheck:
  221. name: Frontend Type Check
  222. runs-on: ubuntu-latest
  223. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  224. steps:
  225. - uses: actions/checkout@v6
  226. - name: Set up Node.js
  227. uses: actions/setup-node@v6
  228. with:
  229. node-version: ${{ env.NODE_VERSION }}
  230. cache: 'npm'
  231. cache-dependency-path: frontend/package-lock.json
  232. - name: Install dependencies
  233. working-directory: frontend
  234. run: npm ci
  235. - name: Run TypeScript check
  236. working-directory: frontend
  237. run: npx tsc --noEmit
  238. frontend-tests:
  239. name: Frontend Tests
  240. runs-on: ubuntu-latest
  241. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  242. needs: [frontend-lint, frontend-typecheck]
  243. steps:
  244. - uses: actions/checkout@v6
  245. - name: Set up Node.js
  246. uses: actions/setup-node@v6
  247. with:
  248. node-version: ${{ env.NODE_VERSION }}
  249. cache: 'npm'
  250. cache-dependency-path: frontend/package-lock.json
  251. - name: Install dependencies
  252. working-directory: frontend
  253. run: npm ci
  254. - name: Run tests
  255. timeout-minutes: 10
  256. working-directory: frontend
  257. run: npm run test:run
  258. frontend-build:
  259. name: Frontend Build
  260. runs-on: ubuntu-latest
  261. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  262. needs: [frontend-tests]
  263. steps:
  264. - uses: actions/checkout@v6
  265. - name: Set up Node.js
  266. uses: actions/setup-node@v6
  267. with:
  268. node-version: ${{ env.NODE_VERSION }}
  269. cache: 'npm'
  270. cache-dependency-path: frontend/package-lock.json
  271. - name: Install dependencies
  272. working-directory: frontend
  273. run: npm ci
  274. - name: Build
  275. working-directory: frontend
  276. run: npm run build
  277. # ============================================================================
  278. # Docker Tests (matches test_docker.sh)
  279. # ============================================================================
  280. # Run the FULL backend test suite inside the test image, sharded 4-way
  281. # so wall-clock matches the host-side backend-tests job. Catches the
  282. # rare-but-real cases where a test passes on the GHA host but fails in
  283. # the python:3.13-slim test image (system-binary version differences,
  284. # locale/timezone, container vs host user, cwd assumptions). Without
  285. # sharding this was a 5-10 min single-runner job; with sharding it's
  286. # ~120-150s per shard running in parallel, gated by max(shard).
  287. docker-backend-tests:
  288. name: Docker Backend Tests (shard ${{ matrix.shard }}/4)
  289. runs-on: ubuntu-latest
  290. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  291. timeout-minutes: 15
  292. strategy:
  293. fail-fast: false
  294. matrix:
  295. shard: [1, 2, 3, 4]
  296. steps:
  297. - uses: actions/checkout@v6
  298. - name: Set up Docker Buildx
  299. uses: docker/setup-buildx-action@v4
  300. # Build the backend-test image with GHA BuildKit cache backend so
  301. # the pip-install layer is shared across the 4 matrix shards AND
  302. # across CI runs. First run on a given requirements.txt is cold
  303. # (~60-90s); subsequent runs are ~5-10s.
  304. - name: Build backend test image (cached)
  305. uses: docker/build-push-action@v7
  306. with:
  307. context: .
  308. file: Dockerfile.test
  309. target: backend-test
  310. load: true
  311. tags: bambuddy-backend-test:latest
  312. cache-from: type=gha,scope=backend-test
  313. cache-to: type=gha,scope=backend-test,mode=max
  314. - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4)
  315. run: |
  316. docker run --rm \
  317. -e TESTING=1 \
  318. -e PYTHONUNBUFFERED=1 \
  319. bambuddy-backend-test:latest \
  320. pytest backend/tests/ \
  321. --tb=short \
  322. --timeout=60 --timeout-method=thread \
  323. -p no:cacheprovider \
  324. -n auto \
  325. --splits 4 --group ${{ matrix.shard }}
  326. docker-test:
  327. name: Docker Build
  328. runs-on: ubuntu-latest
  329. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  330. timeout-minutes: 20
  331. needs: [backend-tests, frontend-build]
  332. steps:
  333. - uses: actions/checkout@v6
  334. # Test 1: Docker Build
  335. - name: Build production image
  336. run: docker build -t bambuddy:test .
  337. - name: Verify backend imports
  338. run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
  339. - name: Verify static files exist
  340. run: docker run --rm bambuddy:test test -d /app/static
  341. # Test 4: Integration Tests
  342. - name: Build integration container
  343. run: docker compose -f docker-compose.test.yml build integration
  344. - name: Start integration container
  345. run: |
  346. docker compose -f docker-compose.test.yml up -d integration
  347. echo "Waiting for container to be healthy..."
  348. for i in {1..30}; do
  349. if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
  350. echo "Container is healthy"
  351. break
  352. fi
  353. sleep 2
  354. done
  355. - name: Test health endpoint
  356. run: |
  357. HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
  358. echo "$HEALTH"
  359. echo "$HEALTH" | grep -q "healthy"
  360. - name: Test API endpoint
  361. run: |
  362. docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
  363. - name: Test static files served
  364. run: |
  365. STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
  366. echo "Static files HTTP status: $STATUS"
  367. [ "$STATUS" = "200" ]
  368. # Test 5: Integration Test Suite (pytest)
  369. - name: Build integration test runner
  370. run: docker compose -f docker-compose.test.yml build integration-test-runner
  371. - name: Run integration test suite
  372. run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
  373. - name: Show logs on failure
  374. if: failure()
  375. run: docker compose -f docker-compose.test.yml logs
  376. - name: Cleanup
  377. if: always()
  378. run: docker compose -f docker-compose.test.yml down -v --remove-orphans