ci.yml 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  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-typecheck:
  45. name: Backend Type Check
  46. runs-on: ubuntu-latest
  47. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  48. steps:
  49. - uses: actions/checkout@v6
  50. - name: Set up Python
  51. uses: actions/setup-python@v6
  52. with:
  53. python-version: ${{ env.PYTHON_VERSION }}
  54. - name: Install the pinned type-check environment
  55. # requirements-typecheck.lock, not requirements.txt: the baseline only
  56. # holds for these exact library versions (see requirements-typecheck.txt).
  57. run: pip install -r requirements-typecheck.lock
  58. - name: Check the lock covers requirements.txt
  59. # A dependency added to requirements.txt but not to the lock would show
  60. # up below as an unresolved import instead of saying what is wrong.
  61. run: |
  62. if ! out=$(pip install --dry-run -r requirements.txt 2>&1); then
  63. echo "$out"
  64. echo "::error::pip could not resolve requirements.txt against the type-check environment."
  65. exit 1
  66. fi
  67. if echo "$out" | grep '^Would install'; then
  68. echo "::error::requirements-typecheck.lock is missing packages from requirements.txt. Regenerate it as described in requirements-typecheck.txt."
  69. exit 1
  70. fi
  71. - name: Run basedpyright
  72. # Errors recorded in .basedpyright/baseline.json pass; any new one fails.
  73. # Lock mode also fails when a baselined error was fixed but the smaller
  74. # baseline was not committed.
  75. run: basedpyright --pythonpath "$(which python)" --baselinemode=lock
  76. backend-security:
  77. name: Backend Security
  78. runs-on: ubuntu-latest
  79. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  80. continue-on-error: true
  81. steps:
  82. - uses: actions/checkout@v6
  83. - name: Set up Python
  84. uses: actions/setup-python@v6
  85. with:
  86. python-version: ${{ env.PYTHON_VERSION }}
  87. - name: Install dependencies
  88. run: |
  89. # Upgrade setuptools too: the runner's Python toolcache ships an old
  90. # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
  91. # A fix exists, so we upgrade rather than --ignore-vuln.
  92. python -m pip install --upgrade pip setuptools
  93. pip install -r requirements.lock
  94. pip install pip-audit
  95. - name: Run pip-audit
  96. run: |
  97. # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).
  98. # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.
  99. #
  100. # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers.
  101. # Advisory says "key length is chosen by the application that uses the library" — no
  102. # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses
  103. # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and
  104. # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently.
  105. pip-audit --desc on \
  106. --ignore-vuln CVE-2026-4539 \
  107. --ignore-vuln CVE-2025-45768
  108. backend-tests:
  109. name: Backend Tests (shard ${{ matrix.shard }}/4)
  110. runs-on: ubuntu-latest
  111. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  112. needs: backend-lint
  113. strategy:
  114. # Don't cancel sibling shards if one fails — we want every shard's
  115. # failure list, not just the first one, so a single PR push shows
  116. # all broken tests in one go.
  117. fail-fast: false
  118. matrix:
  119. shard: [1, 2, 3, 4]
  120. steps:
  121. - uses: actions/checkout@v6
  122. - name: Set up Python
  123. uses: actions/setup-python@v6
  124. with:
  125. python-version: ${{ env.PYTHON_VERSION }}
  126. - name: Cache pip
  127. uses: actions/cache@v5
  128. with:
  129. path: ~/.cache/pip
  130. key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock', 'requirements-dev.txt') }}
  131. restore-keys: |
  132. ${{ runner.os }}-pip-
  133. - name: Install dependencies
  134. run: |
  135. python -m pip install --upgrade pip
  136. # The pinned versions that ship (Docker, installers), not whatever is newest.
  137. pip install -r requirements.lock
  138. pip install -r requirements-dev.txt
  139. - name: Run tests (shard ${{ matrix.shard }}/4)
  140. timeout-minutes: 15
  141. run: |
  142. cd backend
  143. # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
  144. # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
  145. #
  146. # --splits 4 --group N slices the suite across the 4 matrix shards.
  147. # Without a durations file pytest-split balances by test COUNT, which
  148. # is not the same thing as time: measured 2026-08-31, that put 658s of
  149. # the suite's 1046s into shard 1 and 101s into shard 4 -- a 6.5x spread
  150. # that walked shard 1 into the old 10-minute timeout on a docs-only
  151. # commit. tests/.test_durations is committed so the split is balanced
  152. # by measured time instead (1.01x spread). Shard test counts are
  153. # lopsided by design once it is used: shard 4 takes thousands of fast
  154. # unit tests, shard 1 keeps the slow integration ones.
  155. #
  156. # --durations-path must be explicit: pytest-split defaults it to
  157. # $CWD/.test_durations, and this job runs from backend/ while the
  158. # Docker job below runs from the image root. Left implicit, one of
  159. # them silently finds no file and falls back to the count split.
  160. # Regenerate after adding slow tests:
  161. # cd backend && python -m pytest tests/ --store-durations \
  162. # --durations-path tests/.test_durations
  163. python -m pytest tests/ \
  164. --tb=short \
  165. --timeout=60 --timeout-method=thread \
  166. -n auto \
  167. --splits 4 --group ${{ matrix.shard }} \
  168. --durations-path tests/.test_durations
  169. backend-postgres-upgrade:
  170. # The test suite runs on SQLite only. This builds PostgreSQL databases with
  171. # older releases' code, upgrades them and compares each with a fresh
  172. # install (see scripts/check_postgres_upgrade.py).
  173. name: PostgreSQL Upgrade Check
  174. runs-on: ubuntu-latest
  175. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  176. needs: backend-lint
  177. services:
  178. postgres:
  179. # The version the docker-compose example and the wiki use.
  180. image: postgres:16-alpine
  181. env:
  182. POSTGRES_PASSWORD: ci
  183. ports:
  184. - 5432:5432
  185. options: >-
  186. --health-cmd pg_isready
  187. --health-interval 5s
  188. --health-timeout 5s
  189. --health-retries 20
  190. steps:
  191. - uses: actions/checkout@v6
  192. with:
  193. # The release tags: older releases' code builds the databases to upgrade.
  194. fetch-depth: 0
  195. - name: Set up Python
  196. uses: actions/setup-python@v6
  197. with:
  198. python-version: ${{ env.PYTHON_VERSION }}
  199. - name: Cache pip
  200. uses: actions/cache@v5
  201. with:
  202. path: ~/.cache/pip
  203. # Its own key: this job installs the lock only, and finishes long
  204. # before the test shards, so sharing theirs would save a cache
  205. # without the dev packages under their key.
  206. key: ${{ runner.os }}-pip-lock-${{ hashFiles('requirements.lock') }}
  207. restore-keys: |
  208. ${{ runner.os }}-pip-lock-
  209. - name: Install dependencies
  210. run: |
  211. python -m pip install --upgrade pip
  212. pip install -r requirements.lock
  213. - name: Upgrade from older releases and compare with a fresh install
  214. timeout-minutes: 10
  215. run: python scripts/check_postgres_upgrade.py --url postgresql://postgres:ci@localhost:5432/postgres
  216. # ============================================================================
  217. # Frontend Checks
  218. # ============================================================================
  219. frontend-lint:
  220. name: Frontend Lint
  221. runs-on: ubuntu-latest
  222. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  223. steps:
  224. - uses: actions/checkout@v6
  225. - name: Set up Node.js
  226. uses: actions/setup-node@v6
  227. with:
  228. node-version: ${{ env.NODE_VERSION }}
  229. cache: 'npm'
  230. cache-dependency-path: frontend/package-lock.json
  231. - name: Install dependencies
  232. working-directory: frontend
  233. run: npm ci
  234. - name: Run ESLint
  235. working-directory: frontend
  236. run: npm run lint
  237. - name: Unused files and dependencies (knip)
  238. working-directory: frontend
  239. run: npm run check:knip
  240. frontend-security:
  241. name: Frontend Security
  242. runs-on: ubuntu-latest
  243. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  244. continue-on-error: true
  245. steps:
  246. - uses: actions/checkout@v6
  247. - name: Set up Node.js
  248. uses: actions/setup-node@v6
  249. with:
  250. node-version: ${{ env.NODE_VERSION }}
  251. cache: 'npm'
  252. cache-dependency-path: frontend/package-lock.json
  253. - name: Install dependencies
  254. working-directory: frontend
  255. run: npm ci
  256. - name: Run npm audit
  257. working-directory: frontend
  258. run: |
  259. # Only audit production dependencies and filter out npm-internal packages.
  260. # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)
  261. # so we parse package-lock.json directly to get the real prod dep list.
  262. npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true
  263. python3 -c "
  264. import json, sys
  265. data = json.load(open('/tmp/audit.json'))
  266. lock = json.load(open('package-lock.json'))
  267. prod = set()
  268. for path, info in lock.get('packages', {}).items():
  269. if path and not info.get('dev') and not info.get('devOptional'):
  270. prod.add(path.split('node_modules/')[-1])
  271. vulns = data.get('vulnerabilities', {})
  272. # Documented advisory exceptions: high/critical findings whose only offered
  273. # 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
  274. # Keyed by GHSA id. An entry only holds while the fix stays major-only (see
  275. # fix_is_major below) - once upstream backports, the gate fails until we take
  276. # the patch. That is what retired the one entry this list used to carry:
  277. # GHSA-qwww-vcr4-c8h2 (React Router RSC-mode CSRF) shipped in 7.18.2, so the
  278. # pin moved rather than the exception staying.
  279. ALLOWLIST = set()
  280. def advisory_ids(name, seen=None):
  281. seen = seen if seen is not None else set()
  282. if name in seen:
  283. return set()
  284. seen.add(name)
  285. ids = set()
  286. for item in vulns.get(name, {}).get('via', []):
  287. if isinstance(item, dict):
  288. url = item.get('url', '')
  289. if '/advisories/' in url:
  290. ids.add(url.rsplit('/', 1)[-1])
  291. elif isinstance(item, str):
  292. ids |= advisory_ids(item, seen)
  293. return ids
  294. def fix_is_major(v):
  295. fa = v.get('fixAvailable')
  296. return isinstance(fa, dict) and fa.get('isSemVerMajor')
  297. def exempt(name, v):
  298. ids = advisory_ids(name)
  299. return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
  300. fixable = {n: v for n, v in vulns.items()
  301. if n in prod and v.get('severity') in ('high', 'critical')
  302. and v.get('fixAvailable') and not exempt(n, v)}
  303. skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
  304. if fixable:
  305. for name, v in fixable.items():
  306. print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
  307. sys.exit(1)
  308. total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
  309. exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
  310. print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
  311. if exempted:
  312. print('exempted (documented, unreachable): ' + ', '.join(exempted))
  313. "
  314. frontend-typecheck:
  315. name: Frontend Type Check
  316. runs-on: ubuntu-latest
  317. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  318. steps:
  319. - uses: actions/checkout@v6
  320. - name: Set up Node.js
  321. uses: actions/setup-node@v6
  322. with:
  323. node-version: ${{ env.NODE_VERSION }}
  324. cache: 'npm'
  325. cache-dependency-path: frontend/package-lock.json
  326. - name: Install dependencies
  327. working-directory: frontend
  328. run: npm ci
  329. # `tsc -b`, not `tsc --noEmit`: the root tsconfig only holds project
  330. # references, so plain `tsc` checks zero files. Kept out of `npm run
  331. # build` because every installer runs that on the user's machine, and
  332. # the type check alone needs ~1 GB of Node heap (#3181).
  333. - name: Run TypeScript check
  334. working-directory: frontend
  335. run: npm run typecheck
  336. frontend-tests:
  337. name: Frontend Tests
  338. runs-on: ubuntu-latest
  339. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  340. needs: [frontend-lint, frontend-typecheck]
  341. steps:
  342. - uses: actions/checkout@v6
  343. - name: Set up Node.js
  344. uses: actions/setup-node@v6
  345. with:
  346. node-version: ${{ env.NODE_VERSION }}
  347. cache: 'npm'
  348. cache-dependency-path: frontend/package-lock.json
  349. - name: Install dependencies
  350. working-directory: frontend
  351. run: npm ci
  352. - name: Run tests
  353. timeout-minutes: 10
  354. working-directory: frontend
  355. run: npm run test:run
  356. frontend-build:
  357. name: Frontend Build
  358. runs-on: ubuntu-latest
  359. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  360. needs: [frontend-tests]
  361. steps:
  362. - uses: actions/checkout@v6
  363. - name: Set up Node.js
  364. uses: actions/setup-node@v6
  365. with:
  366. node-version: ${{ env.NODE_VERSION }}
  367. cache: 'npm'
  368. cache-dependency-path: frontend/package-lock.json
  369. - name: Install dependencies
  370. working-directory: frontend
  371. run: npm ci
  372. - name: Build
  373. working-directory: frontend
  374. run: npm run build
  375. # ============================================================================
  376. # Docker Tests (matches test_docker.sh)
  377. # ============================================================================
  378. # Run the FULL backend test suite inside the test image, sharded 4-way
  379. # so wall-clock matches the host-side backend-tests job. Catches the
  380. # rare-but-real cases where a test passes on the GHA host but fails in
  381. # the python:3.13-slim test image (system-binary version differences,
  382. # locale/timezone, container vs host user, cwd assumptions). Without
  383. # sharding this was a 5-10 min single-runner job; with sharding it's
  384. # ~120-150s per shard running in parallel, gated by max(shard).
  385. docker-backend-tests:
  386. name: Docker Backend Tests (shard ${{ matrix.shard }}/4)
  387. runs-on: ubuntu-latest
  388. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  389. timeout-minutes: 15
  390. strategy:
  391. fail-fast: false
  392. matrix:
  393. shard: [1, 2, 3, 4]
  394. steps:
  395. - uses: actions/checkout@v6
  396. - name: Set up Docker Buildx
  397. uses: docker/setup-buildx-action@v4
  398. # Build the backend-test image with GHA BuildKit cache backend so
  399. # the pip-install layer is shared across the 4 matrix shards AND
  400. # across CI runs. First run on a given requirements.txt is cold
  401. # (~60-90s); subsequent runs are ~5-10s.
  402. - name: Build backend test image (cached)
  403. uses: docker/build-push-action@v7
  404. with:
  405. context: .
  406. file: Dockerfile.test
  407. target: backend-test
  408. load: true
  409. tags: bambuddy-backend-test:latest
  410. cache-from: type=gha,scope=backend-test
  411. cache-to: type=gha,scope=backend-test,mode=max
  412. - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4)
  413. run: |
  414. docker run --rm \
  415. -e TESTING=1 \
  416. -e PYTHONUNBUFFERED=1 \
  417. bambuddy-backend-test:latest \
  418. pytest backend/tests/ \
  419. --tb=short \
  420. --timeout=60 --timeout-method=thread \
  421. -p no:cacheprovider \
  422. -n auto \
  423. --splits 4 --group ${{ matrix.shard }} \
  424. --durations-path backend/tests/.test_durations
  425. docker-test:
  426. name: Docker Build
  427. runs-on: ubuntu-latest
  428. if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
  429. timeout-minutes: 20
  430. needs: [backend-tests, frontend-build]
  431. steps:
  432. - uses: actions/checkout@v6
  433. # Test 1: Docker Build
  434. - name: Build production image
  435. run: docker build -t bambuddy:test .
  436. - name: Verify backend imports
  437. run: docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"
  438. - name: Verify static files exist
  439. run: docker run --rm bambuddy:test test -d /app/static
  440. # Test 4: Integration Tests
  441. - name: Build integration container
  442. run: docker compose -f docker-compose.test.yml build integration
  443. - name: Start integration container
  444. run: |
  445. docker compose -f docker-compose.test.yml up -d integration
  446. echo "Waiting for container to be healthy..."
  447. for i in {1..30}; do
  448. if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
  449. echo "Container is healthy"
  450. break
  451. fi
  452. sleep 2
  453. done
  454. - name: Test health endpoint
  455. run: |
  456. HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
  457. echo "$HEALTH"
  458. echo "$HEALTH" | grep -q "healthy"
  459. - name: Test API endpoint
  460. run: |
  461. docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings
  462. - name: Test static files served
  463. run: |
  464. STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
  465. echo "Static files HTTP status: $STATUS"
  466. [ "$STATUS" = "200" ]
  467. # Test 5: Integration Test Suite (pytest)
  468. - name: Build integration test runner
  469. run: docker compose -f docker-compose.test.yml build integration-test-runner
  470. - name: Run integration test suite
  471. run: docker compose -f docker-compose.test.yml run --rm integration-test-runner
  472. - name: Show logs on failure
  473. if: failure()
  474. run: docker compose -f docker-compose.test.yml logs
  475. - name: Cleanup
  476. if: always()
  477. run: docker compose -f docker-compose.test.yml down -v --remove-orphans