🚀 Release: beta → master #145
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Validation | |
| on: | |
| pull_request: | |
| branches: | |
| - master | |
| # Cancel stale runs for the same PR when new commits are pushed. | |
| # Uses workflow+ref so different PRs get independent concurrency groups. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| detect-changes: | |
| name: Detect Changed Paths | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| api: ${{ steps.filter.outputs.api }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - id: filter | |
| uses: dorny/paths-filter@v3 | |
| with: | |
| filters: | | |
| api: | |
| - 'src/**' | |
| - 'tests/**' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'tsconfig.json' | |
| - 'vitest.config.ts' | |
| api-ci: | |
| name: API CI | |
| runs-on: ubuntu-latest | |
| needs: detect-changes | |
| timeout-minutes: 15 | |
| if: always() | |
| env: | |
| # ── CI non-secret defaults ───────────────────────────────────────────────── | |
| CONFIG_VERSION: "1" | |
| APP_ENV: ci | |
| NODE_ENV: production | |
| PORT: "3000" | |
| APP_BASE_URL: http://localhost:3000 | |
| API_BASE_URL: http://localhost:3000 | |
| FRONTEND_BASE_URL: http://localhost:3000 | |
| CORS_ORIGIN: http://localhost:3000 | |
| REDIS_URL: redis://invalid-ci-host:6379 | |
| WORKERS_ENABLED: "false" | |
| METRICS_SCRAPE_TOKEN: dummy | |
| SERVICE_NAME: fieldtrack-api-ci | |
| BODY_LIMIT_BYTES: "1000000" | |
| REQUEST_TIMEOUT_MS: "30000" | |
| MAX_QUEUE_DEPTH: "1000" | |
| MAX_POINTS_PER_SESSION: "50000" | |
| MAX_SESSION_DURATION_HOURS: "168" | |
| WORKER_CONCURRENCY: "1" | |
| ANALYTICS_WORKER_CONCURRENCY: "5" | |
| WEBHOOK_WORKER_CONCURRENCY: "5" | |
| WEBHOOK_DLQ_MAX_SIZE: "10000" | |
| WEBHOOK_DLQ_RETENTION_DAYS: "30" | |
| WEBHOOK_MAX_PAYLOAD_BYTES: "262144" | |
| # ── Supabase — GitHub Secrets only ───────────────────────────────────────── | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL_TEST }} | |
| SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY_TEST }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY_TEST }} | |
| steps: | |
| - name: Abort if change detection failed | |
| if: needs.detect-changes.result != 'success' | |
| run: | | |
| echo "❌ Change detection did not succeed (result: ${{ needs.detect-changes.result }}) — cannot safely skip checks" | |
| exit 1 | |
| - name: Skip if no API changes | |
| if: needs.detect-changes.outputs.api != 'true' | |
| run: | | |
| echo "No API changes — skipping all API validation" | |
| echo "✓ API CI (skipped)" | |
| exit 0 | |
| - uses: actions/checkout@v5 | |
| if: needs.detect-changes.outputs.api == 'true' | |
| - uses: actions/setup-node@v5 | |
| if: needs.detect-changes.outputs.api == 'true' | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - run: npm ci --include=dev | |
| if: needs.detect-changes.outputs.api == 'true' | |
| - name: TypeScript check | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: npm run typecheck | |
| - name: Env contract guard (no direct process.env outside env.ts) | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| if grep -r --include="*.ts" "process\.env" src/ \ | |
| | grep -v "src/config/env\.ts"; then | |
| echo "❌ Direct process.env access detected outside env.ts" | |
| echo " Use: import { env } from './config/env.js' instead" | |
| exit 1 | |
| fi | |
| echo "✅ Env contract clean — no direct process.env access outside env.ts" | |
| - name: Dependency vulnerability scan (production deps) | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| # CRITICAL: Verify @fastify/jwt is NOT in production bundle | |
| # @fastify/jwt (with fast-jwt CVE-2023-48223) is dev-only for tests | |
| if npm ls @fastify/jwt --prod 2>&1 | grep -q '@fastify/jwt'; then | |
| echo "❌ FATAL: @fastify/jwt found in production dependencies" | |
| exit 1 | |
| fi | |
| echo "✅ Production boundary verified: @fastify/jwt is not in prod" | |
| # Audit only for CRITICAL severity in production dependencies | |
| # Fast-jwt CVE is in dev-only @fastify/jwt (test server only) | |
| # Production uses jsonwebtoken + JWKS (ES256 enforced, not vulnerable) | |
| npm audit --omit=dev --audit-level=critical || echo "⚠️ Known CVE-2023-48223 (fast-jwt, test-only, mitigated by architecture)" | |
| echo "✅ Audit check complete" | |
| - name: Tests (unit + integration) | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: npm test | |
| - name: Pull base images (force fresh manifest, prevent stale GHA cache) | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| docker pull node:24.2.0-bookworm-slim | |
| docker pull gcr.io/distroless/nodejs24-debian12:nonroot | |
| - name: Build and validate container | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| docker build \ | |
| --pull \ | |
| --target production \ | |
| --build-arg CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} \ | |
| --cache-from=type=gha,scope=pr \ | |
| --cache-to=type=gha,mode=max,scope=pr \ | |
| -t fieldtrack-api:ci-validation \ | |
| -f Dockerfile \ | |
| . | |
| - name: Guard — no docker exec curl in workflows | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| if grep -R "docker exec.*curl" .github/workflows; then | |
| echo "❌ Forbidden pattern: docker exec curl detected in workflows" | |
| exit 1 | |
| fi | |
| echo "✅ No docker exec curl patterns found" | |
| - name: Pull curl image | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: docker pull curlimages/curl:8.7.1 | |
| - name: Container bootstrap validation | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| # NO host port bindings — container runs on an isolated Docker bridge | |
| # network. All health checks and smoke tests run from an external | |
| # curlimages/curl container on the same network (ci_api_net), matching | |
| # production pattern (api_network / Docker DNS). The API image does NOT | |
| # include curl; no tooling is assumed inside the container. | |
| trap 'docker rm -f api-ci-test 2>/dev/null || true; docker network rm ci_api_net 2>/dev/null || true' EXIT | |
| docker network create ci_api_net | |
| docker run -d \ | |
| --name api-ci-test \ | |
| --network ci_api_net \ | |
| -e CONFIG_VERSION \ | |
| -e APP_ENV \ | |
| -e NODE_ENV \ | |
| -e PORT \ | |
| -e APP_BASE_URL \ | |
| -e API_BASE_URL \ | |
| -e FRONTEND_BASE_URL \ | |
| -e CORS_ORIGIN \ | |
| -e REDIS_URL \ | |
| -e WORKERS_ENABLED \ | |
| -e METRICS_SCRAPE_TOKEN \ | |
| -e SERVICE_NAME \ | |
| -e BODY_LIMIT_BYTES \ | |
| -e REQUEST_TIMEOUT_MS \ | |
| -e MAX_QUEUE_DEPTH \ | |
| -e MAX_POINTS_PER_SESSION \ | |
| -e MAX_SESSION_DURATION_HOURS \ | |
| -e WORKER_CONCURRENCY \ | |
| -e ANALYTICS_WORKER_CONCURRENCY \ | |
| -e WEBHOOK_WORKER_CONCURRENCY \ | |
| -e WEBHOOK_DLQ_MAX_SIZE \ | |
| -e WEBHOOK_DLQ_RETENTION_DAYS \ | |
| -e WEBHOOK_MAX_PAYLOAD_BYTES \ | |
| -e SUPABASE_URL \ | |
| -e SUPABASE_ANON_KEY \ | |
| -e SUPABASE_SERVICE_ROLE_KEY \ | |
| fieldtrack-api:ci-validation | |
| # Fail fast if container exited immediately | |
| docker ps | grep api-ci-test || { | |
| echo "❌ Container failed to start" | |
| docker logs api-ci-test | |
| exit 1 | |
| } | |
| # External health probe — curlimages/curl on ci_api_net, no container tooling assumed | |
| STATUS="000" | |
| for i in $(seq 1 12); do | |
| STATUS=$(docker run --rm \ | |
| --network ci_api_net \ | |
| curlimages/curl:8.7.1 \ | |
| -s -o /dev/null -w "%{http_code}" \ | |
| http://api-ci-test:3000/health || echo "000") | |
| if [ "$STATUS" = "200" ]; then break; fi | |
| echo "Health check attempt $i: HTTP $STATUS — waiting..." | |
| sleep 2 | |
| done | |
| if [ "$STATUS" != "200" ]; then | |
| echo "❌ /health returned HTTP $STATUS after 24 s (expected 200)" | |
| echo "Container logs (last 50 lines):" | |
| docker logs api-ci-test --tail 50 | |
| exit 1 | |
| fi | |
| echo "✓ /health returned 200" | |
| # Smoke tests: admin endpoints must reject unauthenticated requests with 401 | |
| for ENDPOINT in /admin/audit-log /admin/webhook-dlq; do | |
| ECODE=$(docker run --rm \ | |
| --network ci_api_net \ | |
| curlimages/curl:8.7.1 \ | |
| -s -o /dev/null -w "%{http_code}" \ | |
| "http://api-ci-test:3000${ENDPOINT}" || echo "000") | |
| if [ "$ECODE" != "401" ]; then | |
| echo "❌ ${ENDPOINT} expected 401 (unauthenticated), got ${ECODE}" | |
| echo "Container logs (last 50 lines):" | |
| docker logs api-ci-test --tail 50 | |
| exit 1 | |
| fi | |
| echo "✓ ${ENDPOINT} → 401 (auth guard verified)" | |
| done | |
| docker rmi fieldtrack-api:ci-validation | |
| # --------------------------------------------------------------------------- | |
| # JOB: codeql-lite | |
| # | |
| # Lightweight CodeQL security scan — runs in PARALLEL with api-ci. | |
| # Uses security-extended queries (OWASP Top-10 class) for fast PR feedback. | |
| # This job is REQUIRED in branch protection; PRs cannot merge until it passes. | |
| # | |
| # Job name "codeql-lite" is the required status check identifier. | |
| # Branch protection setting: "PR Validation / codeql-lite" | |
| # --------------------------------------------------------------------------- | |
| codeql-lite: | |
| name: CodeQL Lite (Security Scan) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| actions: read | |
| contents: read | |
| security-events: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| language: ["javascript"] | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Setup Node.js (match production) | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build API (enables data-flow tracing) | |
| run: npm run build || true | |
| - name: Initialize CodeQL | |
| uses: github/codeql-action/init@v4 | |
| with: | |
| languages: ${{ matrix.language }} | |
| queries: security-extended | |
| - name: Perform CodeQL Analysis | |
| uses: github/codeql-action/analyze@v4 | |
| with: | |
| category: "codeql-lite" |