🚀 Release: beta → master #136
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 }} | |
| infra: ${{ steps.filter.outputs.infra }} | |
| 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' | |
| infra: | |
| - 'infra/**' | |
| - '.github/workflows/**' | |
| 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: Container bootstrap validation | |
| if: needs.detect-changes.outputs.api == 'true' | |
| run: | | |
| # NO host port bindings — container runs on an isolated Docker bridge | |
| # network. All checks use docker exec to reach the container directly, | |
| # matching the production pattern (api_network / Docker DNS). | |
| 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 | |
| STATUS="000" | |
| for i in $(seq 1 12); do | |
| STATUS=$(docker exec api-ci-test \ | |
| curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health 2>/dev/null || 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)" | |
| docker logs api-ci-test --tail 50 | |
| docker rm -f api-ci-test || true | |
| docker network rm ci_api_net || true | |
| 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 exec api-ci-test \ | |
| curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000${ENDPOINT}" 2>/dev/null || echo "000") | |
| if [ "$ECODE" != "401" ]; then | |
| echo "❌ ${ENDPOINT} expected 401 (unauthenticated), got ${ECODE}" | |
| docker logs api-ci-test --tail 50 | |
| docker rm -f api-ci-test || true | |
| docker network rm ci_api_net || true | |
| exit 1 | |
| fi | |
| echo "✓ ${ENDPOINT} → 401 (auth guard verified)" | |
| done | |
| docker rm -f api-ci-test | |
| docker network rm ci_api_net | |
| docker rmi fieldtrack-api:ci-validation | |
| infra-ci: | |
| name: Infra CI | |
| runs-on: ubuntu-latest | |
| needs: detect-changes | |
| timeout-minutes: 10 | |
| if: always() | |
| 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 infra changes | |
| if: needs.detect-changes.outputs.infra != 'true' | |
| run: | | |
| echo "No infra changes — skipping all infra validation" | |
| echo "✓ Infra CI (skipped)" | |
| exit 0 | |
| - uses: actions/checkout@v5 | |
| if: needs.detect-changes.outputs.infra == 'true' | |
| - name: Validate nginx config | |
| if: needs.detect-changes.outputs.infra == 'true' | |
| run: | | |
| sed \ | |
| -e 's/__ACTIVE_CONTAINER__/api-blue/g' \ | |
| -e 's/__API_HOSTNAME__/api.test.local/g' \ | |
| infra/nginx/api.conf > /tmp/nginx.conf | |
| if grep -q '__[A-Z_]*__' /tmp/nginx.conf; then | |
| echo "❌ Unreplaced placeholders" | |
| exit 1 | |
| fi | |
| mkdir -p /tmp/ssl | |
| openssl req -x509 -nodes -days 1 \ | |
| -newkey rsa:2048 \ | |
| -keyout /tmp/ssl/origin.key \ | |
| -out /tmp/ssl/origin.crt \ | |
| -subj "/CN=localhost" | |
| docker run --rm \ | |
| -v /tmp/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ | |
| -v /tmp/ssl:/etc/ssl/api:ro \ | |
| nginx:1.27-alpine nginx -t | |
| # --------------------------------------------------------------------------- | |
| # JOB: codeql-lite | |
| # | |
| # Lightweight CodeQL security scan — runs in PARALLEL with api-ci and infra-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" |