Skip to content

🚀 Release: beta → master #145

🚀 Release: beta → master

🚀 Release: beta → master #145

Workflow file for this run

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"