Skip to content

🚀 Release: beta → master #120

🚀 Release: beta → master

🚀 Release: beta → master #120

Workflow file for this run

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