Skip to content

Security: harden Content-Security-Policy and verify report pipeline #3

Security: harden Content-Security-Policy and verify report pipeline

Security: harden Content-Security-Policy and verify report pipeline #3

Workflow file for this run

name: CI

Check failure on line 1 in .github/workflows/ci.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/ci.yml

Invalid workflow file

(Line: 295, Col: 9): Unrecognized named-value: 'matrix'. Located at position 73 within expression: needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs[matrix.workspace] == 'true'
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
inputs:
force_full_run:
description: 'Force a full CI run'
type: boolean
default: false
jobs:
# ──────────────────────────────────────────────
# Job 0: Detect changes
# ──────────────────────────────────────────────
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
client: ${{ steps.filter.outputs.client }}
sdk: ${{ steps.filter.outputs.sdk }}
shared: ${{ steps.filter.outputs.shared }}
force_full_run: ${{ github.event.inputs.force_full_run || 'false' }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
backend:
- 'backend/**'
- 'shared/**'
- 'package.json'
- 'package-lock.json'
client:
- 'client/**'
- 'shared/**'
- 'package.json'
- 'package-lock.json'
sdk:
- 'sdk/**'
- 'shared/**'
- 'package.json'
- 'package-lock.json'
shared:
- 'shared/**'
- 'package.json'
- 'package-lock.json'
# ──────────────────────────────────────────────
# Job 1: Validate environment variables
# Must pass before the build job starts.
# ──────────────────────────────────────────────
validate-env:
name: Validate Environment Variables
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.client == 'true' || needs.changes.outputs.sdk == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
# ── Structural check (always — PRs + pushes) ──────────────────────────
# Secret-free: asserts manifests and .env.example files are in sync.
# Fails fast on config drift without needing real secrets, so forked /
# contributor PRs are not broken by absent secrets.
- name: Structural env check (manifests ↔ .env.example)
run: |
node scripts/check-env-docs.js
node backend/scripts/validate-env.js --structural
node client/scripts/validate-env.js --structural
# ── Strict presence check (pushes to protected branches only) ─────────
# Real secrets are injected here; the validators hard-fail if any
# REQUIRED var is missing (issue #601 — CI fails fast).
- name: Validate client environment variables (strict)
if: github.event_name == 'push'
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
run: node client/scripts/validate-env.js
- name: Validate backend environment variables (strict)
if: github.event_name == 'push'
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ADMIN_API_KEY: ${{ secrets.ADMIN_API_KEY }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
SOROBAN_CONTRACT_ADDRESS: ${{ secrets.SOROBAN_CONTRACT_ADDRESS }}
STELLAR_NETWORK_URL: ${{ secrets.STELLAR_NETWORK_URL }}
run: node backend/scripts/validate-env.js
# ──────────────────────────────────────────────
# Job 2: Validate dependencies
# Ensures no 'latest' versions and lockfiles are up to date
# ──────────────────────────────────────────────
validate-dependencies:
name: Validate Dependencies
runs-on: ubuntu-latest
needs: [changes, validate-env]
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.client == 'true' || needs.changes.outputs.sdk == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Reject non-npm lockfiles (pnpm-lock.yaml, yarn.lock)
# This repo standardises on npm. Committing pnpm or yarn lockfiles
# causes non-reproducible installs (issue #557).
run: |
bad_files=$(find . -not -path './.git/*' \( -name 'pnpm-lock.yaml' -o -name 'yarn.lock' \))
if [ -n "$bad_files" ]; then
echo "❌ Non-npm lockfile(s) found — only package-lock.json is permitted:"
echo "$bad_files"
echo ""
echo "Remove the file(s) and add them to .gitignore. See issue #557."
exit 1
fi
echo "✅ No pnpm or yarn lockfiles found"
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
- name: Validate intentional package names
run: node scripts/check-package-names.js
- name: Check for 'latest' in client package.json
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
run: |
if grep -q '"latest"' client/package.json; then
echo "❌ Error: 'latest' version found in client/package.json"
echo "Please specify an exact version or semver range."
exit 1
fi
echo "✅ No 'latest' versions found in client/package.json"
- name: Check for 'latest' in backend package.json
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }}
run: |
if grep -q '"latest"' backend/package.json; then
echo "❌ Error: 'latest' version found in backend/package.json"
echo "Please specify an exact version or semver range."
exit 1
fi
echo "✅ No 'latest' versions found in backend/package.json"
- name: Validate client dependencies
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
working-directory: client
run: node scripts/validate-deps.js
- name: Verify client lockfile is committed
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
run: |
if [ ! -f "client/package-lock.json" ]; then
echo "❌ Error: client/package-lock.json not found"
echo "Please commit the lockfile to ensure reproducible builds."
exit 1
fi
echo "✅ Client lockfile is committed"
- name: Verify backend lockfile is committed
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }}
run: |
if [ ! -f "backend/package-lock.json" ]; then
echo "❌ Error: backend/package-lock.json not found"
echo "Please commit the lockfile to ensure reproducible builds."
exit 1
fi
echo "✅ Backend lockfile is committed"
- name: Verify exactly one Next.js config exists (client/next.config.mjs)
run: |
count=$(find . -maxdepth 1 -name "next.config.*" | wc -l)
if [ "$count" -ne 0 ]; then
echo "❌ Stale root-level next.config.* file(s) detected:"
find . -maxdepth 1 -name "next.config.*"
echo "Only client/next.config.mjs should exist."
exit 1
fi
if [ ! -f "client/next.config.mjs" ]; then
echo "❌ client/next.config.mjs is missing."
exit 1
fi
client_count=$(find client -maxdepth 1 -name "next.config.*" | wc -l)
if [ "$client_count" -ne 1 ]; then
echo "❌ Expected exactly 1 Next.js config under client/, found $client_count:"
find client -maxdepth 1 -name "next.config.*"
exit 1
fi
echo "✅ Exactly one Next.js config found: client/next.config.mjs"
- name: Install client dependencies and verify lockfile
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
working-directory: client
run: |
npm ci
if ! git diff --exit-code package-lock.json; then
echo "❌ Error: package-lock.json is out of sync"
echo "Please run 'npm install' and commit the updated lockfile."
exit 1
fi
echo "✅ Client lockfile is up to date"
- name: Install backend dependencies and verify lockfile
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }}
working-directory: backend
run: |
npm ci
if ! git diff --exit-code package-lock.json; then
echo "❌ Error: package-lock.json is out of sync"
echo "Please run 'npm install' and commit the updated lockfile."
exit 1
fi
echo "✅ Backend lockfile is up to date"
# ──────────────────────────────────────────────
# Job 2b: Validate issue governance (labels, triage policy, templates)
# Runs unconditionally — governance files must always be consistent.
# Refs: #114, docs/issue-triage-policy.md, .github/labels.yml
# ──────────────────────────────────────────────
validate-governance:
name: Validate Issue Governance
runs-on: ubuntu-latest
needs: changes
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
- name: Validate labels taxonomy, triage policy, and issue templates
run: node scripts/check-issue-governance.js
- name: Run governance unit tests
run: node --test scripts/check-issue-governance.test.js
- name: Check label drift against GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node scripts/check-label-drift.js
- name: Validate CONTRIBUTING.md onboarding guide
run: node scripts/check-contributing.js
- name: Run CONTRIBUTING guide unit tests
run: node --test scripts/check-contributing.test.js
# ──────────────────────────────────────────────
# Job 3: Dependency vulnerability scan (issue #1079)
# Re-establishes the audit gate that pr-728 removed. Runs once per
# npm workspace and gates on scripts/security-audit-gate.js, which
# reads .github/dependency-audit-allowlist.json and docs/security/
# dependency-triage.md. Drops --omit=dev so that dev-time CVEs
# (e.g. compromised eslint plugins) are surfaced.
#
# Skip-propagation: when no workspace files change, every matrix entry
# is skipped, so the aggregate `security-audit` is `skipped`. Downstream
# `build-client`, `test-client`, `test-backend`, `test-sdk` each declare
# `needs: security-audit`, so they will also be skipped. This is NOT a
# regression: each downstream already has its own workspace-gated `if:`
# and would skip in that scenario anyway. Do NOT remove the element-level
# `if:` on this matrix, that creates a real regression where downstream
# tests run without their own workspace being touched.
# ──────────────────────────────────────────────
security-audit:
name: Security Audit (${{ matrix.workspace }})
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs[matrix.workspace] == 'true' }}
strategy:
fail-fast: false
matrix:
workspace: [backend, client, sdk, shared]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ${{ matrix.workspace }}/package-lock.json
- name: Install ${{ matrix.workspace }} dependencies
working-directory: ${{ matrix.workspace }}
run: npm ci --ignore-scripts
- name: Run dependency audit gate
working-directory: ${{ matrix.workspace }}
run: node ../scripts/security-audit-gate.js ${{ matrix.workspace }} ${{ matrix.workspace }}
# ──────────────────────────────────────────────
# Job 4: Build the client
# Only runs if validate-env passes.
# ──────────────────────────────────────────────
build-client:
name: Build Client
runs-on: ubuntu-latest
needs: [changes, validate-dependencies, security-audit]
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
cache-dependency-path: client/package-lock.json
- name: Install client dependencies
working-directory: client
run: npm ci
- name: Build client
working-directory: client
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
run: npm run build
# ──────────────────────────────────────────────
# Job 4: SDK — install, build, test
# Runs in parallel with build-client and test-backend.
# Blocks merges on any SDK regression.
# ──────────────────────────────────────────────
test-sdk:
name: SDK — Build & Test
runs-on: ubuntu-latest
needs: [changes, validate-env, security-audit]
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.sdk == 'true' }}
defaults:
run:
working-directory: sdk
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
# Matches sdk/.tool-versions (nodejs 23.4.0)
node-version: "23.4.0"
cache: "npm"
cache-dependency-path: sdk/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run typecheck
run: npm run typecheck
- name: Run lint
run: npm run lint
- name: Build
run: npm run build
- name: Run tests
# ESM Jest requires the experimental VM modules flag
env:
NODE_OPTIONS: --experimental-vm-modules
run: npm test -- --ci --coverage
- name: Upload test artifacts on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: sdk-test-results
path: |
sdk/coverage/
retention-days: 7
# ──────────────────────────────────────────────
# Job 5: Run backend tests
# Only runs if validate-env passes.
# ──────────────────────────────────────────────
test-client:
name: Test Client
runs-on: ubuntu-latest
needs: [changes, validate-env, security-audit]
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
cache-dependency-path: client/package-lock.json
- name: Install client dependencies
working-directory: client
run: npm ci
- name: Run client unit tests
working-directory: client
run: npm test
test-backend:
name: Test Backend
runs-on: ubuntu-latest
needs: [changes, validate-dependencies, security-audit]
if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
cache-dependency-path: backend/package-lock.json
- name: Install backend dependencies
working-directory: backend
run: npm install
- name: Run backend tests
working-directory: backend
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
PORT: ${{ secrets.PORT }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
SOROBAN_CONTRACT_ADDRESS: ${{ secrets.SOROBAN_CONTRACT_ADDRESS }}
STELLAR_NETWORK_URL: ${{ secrets.STELLAR_NETWORK_URL }}
run: npm test -- --coverage --ci