🚀 Release: beta → master #120
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 | |
| concurrency: | |
| group: pr-${{ github.event.pull_request.number }} | |
| 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: npm audit --omit=dev --audit-level=high | |
| - 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: | | |
| docker run -d \ | |
| --name api-ci-test \ | |
| -p 127.0.0.1:3001:3000 \ | |
| -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=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3001/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)" | |
| docker logs api-ci-test --tail 50 | |
| exit 1 | |
| fi | |
| # Smoke tests: admin endpoints must reject unauthenticated requests with 401 | |
| for ENDPOINT in /admin/audit-log /admin/webhook-dlq; do | |
| ECODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:3001${ENDPOINT}" || echo "000") | |
| if [ "$ECODE" != "401" ]; then | |
| echo "❌ ${ENDPOINT} expected 401 (unauthenticated), got ${ECODE}" | |
| docker logs api-ci-test --tail 50 | |
| exit 1 | |
| fi | |
| echo "✓ ${ENDPOINT} → 401 (auth guard verified)" | |
| done | |
| docker rm -f api-ci-test | |
| 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/__BACKEND_PORT__/3001/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 |