Skip to content

CI/CD Pipeline

CI/CD Pipeline #3422

Workflow file for this run

name: CI/CD Pipeline
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
branches: [main]
schedule:
# Run quality gates daily at 2 AM UTC
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
run_quality_gates:
description: "Run comprehensive quality gates"
required: false
default: false
type: boolean
# Group runs by workflow + branch/PR and cancel in-progress runs on new pushes
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: "20"
PNPM_VERSION: "10.18.3"
NODE_OPTIONS: "--max-old-space-size=8192"
# Disable Nx daemon in CI to prevent hanging issues
NX_DAEMON: false
# Skip Husky hooks in CI to prevent build loops
HUSKY: 0
CI: true
# E2E test parallelism - number of shards for Playwright test distribution
# Reduced from 20 to 12 due to systematic failures with 20 shards (resource contention)
E2E_SHARDS: 12
# Option B: Disable database cache if Option A (caching full .nx dir) doesn't work
# Uncomment these three lines to fall back to legacy file-based caching:
# NX_DISABLE_DB: true
# NX_DB_CACHE: false
# NX_REJECT_UNKNOWN_LOCAL_CACHE: false
jobs:
# Syncpack - validates and auto-fixes dependency versions
syncpack:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Setup Nx cache
uses: actions/cache@v4
with:
# Cache full .nx dir (includes workspace-data DB) for cross-run cache sharing
path: .nx
# More precise cache key - only hash files that actually affect task execution
key: ${{ runner.os }}-nx-v3-${{ hashFiles('nx.json', '**/project.json', 'package.json') }}
# Fallback to broader keys for better cache hits
restore-keys: |
${{ runner.os }}-nx-v3-${{ hashFiles('nx.json', '**/project.json') }}
${{ runner.os }}-nx-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check dependency versions (PRs)
if: github.event_name == 'pull_request'
run: pnpm nx syncpack:lint bibgraph
- name: Fix dependency versions (main branch)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
pnpm syncpack fix-mismatches
pnpm syncpack format
- name: Check for changes
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: changes
run: |
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Update lockfile and commit fixes
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.changes.outputs.changed == 'true'
run: |
pnpm install --no-frozen-lockfile
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore(deps): auto-fix syncpack mismatches [skip ci]"
git push
# Determine next version using semantic-release dry-run
# This runs BEFORE validate so we can build with the correct version
determine-version:
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
outputs:
next-version: ${{ steps.version.outputs.next-version }}
should-release: ${{ steps.version.outputs.should-release }}
updated-package-json: ${{ steps.version.outputs.updated-package-json }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Determine and update next version
id: version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Run semantic-release in dry-run mode to get the next version
OUTPUT=$(npx semantic-release --dry-run 2>&1) || true
# Extract version from output (looks for "The next release version is X.X.X")
VERSION=$(echo "$OUTPUT" | grep -oP "The next release version is \K[0-9]+\.[0-9]+\.[0-9]+" || echo "")
if [ -n "$VERSION" ]; then
# Write outputs (key=value pairs only)
{
echo "next-version=$VERSION"
echo "should-release=true"
echo "updated-package-json=true"
} >> "$GITHUB_OUTPUT"
# Log informational messages to stdout (not to GITHUB_OUTPUT)
echo "Next version will be: $VERSION"
# Update package.json with next version for build
jq --arg version "$VERSION" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
echo "Updated package.json with version: $VERSION"
# Commit and push the version update
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
git commit -m "chore(ci): bump version to $VERSION for build [skip ci]"
git push origin main
else
# No new release needed, use current version from package.json
VERSION=$(jq -r .version package.json)
# Write outputs (key=value pairs only)
{
echo "next-version=$VERSION"
echo "should-release=false"
echo "updated-package-json=false"
} >> "$GITHUB_OUTPUT"
# Log informational message to stdout
echo "No new release needed, using current version: $VERSION"
fi
# Main validation job - build, typecheck, lint, and all tests in one job
# Consolidating reduces setup overhead and lets Nx fully optimize the task graph
validate:
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [determine-version]
# Run even when determine-version is skipped (for PRs/non-main branches)
if: always() && !cancelled()
outputs:
should-deploy: ${{ steps.deploy.outputs.should-deploy }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Setup Nx cache
uses: actions/cache@v4
with:
# Cache full .nx dir (includes workspace-data DB) for cross-run cache sharing
path: .nx
# More precise cache key - only hash files that actually affect task execution
key: ${{ runner.os }}-nx-v3-${{ hashFiles('nx.json', '**/project.json', 'package.json') }}
# Fallback to broader keys for better cache hits
restore-keys: |
${{ runner.os }}-nx-v3-${{ hashFiles('nx.json', '**/project.json') }}
${{ runner.os }}-nx-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and typecheck all projects
timeout-minutes: 25
env:
GITHUB_PAGES: true
NODE_OPTIONS: --max-old-space-size=8192
# Pass version from determine-version job to vite build
NEXT_RELEASE_VERSION: ${{ needs.determine-version.outputs.next-version }}
run: pnpm nx run-many -t build typecheck --parallel=3
- name: Lint all projects
timeout-minutes: 20
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm nx run-many -t lint --parallel=3
- name: Run tests
timeout-minutes: 6
env:
NODE_OPTIONS: --max-old-space-size=3072
NX_PARALLEL: 1
run: |
# CI ULTRA-OPTIMIZED: Run only true unit tests with coverage, exclude all component/integration tests
# Run packages individually for precise control
pnpm nx test:coverage client --skip-nx-cache &
pnpm nx test:coverage utils --skip-nx-cache &
pnpm nx test:coverage ui --skip-nx-cache &
pnpm nx test:coverage cli --skip-nx-cache &
# For web app, run only unit tests with coverage (not component/integration)
pnpm nx test:coverage web --skip-nx-cache --testNamePattern="\.unit\.test\." &
wait
- name: Merge coverage reports
run: |
echo "=== Merging coverage reports from all packages ==="
mkdir -p coverage
# Copy all coverage directories to root
for dir in packages/*/coverage apps/*/coverage; do
if [ -d "$dir" ]; then
echo "Found coverage: $dir"
# Copy contents, not the directory itself
cp -r "$dir"/* coverage/ 2>/dev/null || true
fi
done
# List what we have
if [ -d "coverage" ] && [ "$(ls -A coverage 2>/dev/null)" ]; then
echo "✅ Coverage merged successfully"
ls -la coverage/
else
echo "⚠️ No coverage files found to merge"
fi
- name: Verify build artifacts before upload
run: |
echo "=== Verifying build artifacts before upload ==="
if [ ! -d "dist" ]; then
echo "❌ dist directory not found"
exit 1
fi
echo "✅ dist directory found"
find dist -maxdepth 2 -type f -o -type d | head -20
if [ ! -d "dist/apps" ]; then
echo "❌ dist/apps directory not found"
exit 1
fi
echo "✅ dist/apps directory found"
if [ ! -d "dist/apps/web" ]; then
echo "❌ dist/apps/web directory not found"
exit 1
fi
echo "✅ dist/apps/web directory found"
echo "=== Build artifacts verified successfully ==="
- name: Upload build artifacts
uses: actions/upload-artifact@v5
with:
name: build-artifacts
path: dist/
retention-days: 1
- name: Upload coverage
uses: actions/upload-artifact@v5
if: always()
with:
name: coverage-all
path: coverage/
retention-days: 7
if-no-files-found: ignore
- name: Determine deployment
id: deploy
run: |
if { [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; } && [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "should-deploy=true" >> "${GITHUB_OUTPUT}"
else
echo "should-deploy=false" >> "${GITHUB_OUTPUT}"
fi
# Security audit - Check for vulnerabilities (runs in parallel with validate)
security-audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Setup Nx cache
uses: actions/cache@v4
with:
path: .nx
key: ${{ runner.os }}-nx-v3-${{ hashFiles('.github/workflows/ci.yml', 'nx.json', '**/project.json', 'pnpm-lock.yaml', 'tsconfig*.json') }}
restore-keys: |
${{ runner.os }}-nx-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run security audit
run: pnpm audit --audit-level moderate
# Setup E2E sharding matrix dynamically from E2E_SHARDS env var
setup-e2e:
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
shards: ${{ steps.set-matrix.outputs.shards }}
steps:
- name: Generate shard matrix
id: set-matrix
run: |
shards="[$(seq -s, 1 "$E2E_SHARDS")]"
echo "shards=$shards" >> "$GITHUB_OUTPUT"
echo "Generated shard matrix: $shards"
# E2E tests - DISABLED - Full application tests (run after validate)
# Uses matrix sharding to run tests in parallel across E2E_SHARDS runners
# Commented out to disable - currently failing due to preview server issues
# e2e:
# runs-on: ubuntu-latest
# timeout-minutes: 45
# needs: [validate, setup-e2e]
# if: always() && needs.validate.result == 'success'
# strategy:
# fail-fast: true
# matrix:
# shard: ${{ fromJson(needs.setup-e2e.outputs.shards) }}
# steps:
# - name: Checkout
# uses: actions/checkout@v6
# - name: Setup Node.js
# uses: actions/setup-node@v6
# with:
# node-version: ${{ env.NODE_VERSION }}
# - name: Setup pnpm
# uses: pnpm/action-setup@v3
# with:
# version: ${{ env.PNPM_VERSION }}
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Download build artifacts
# uses: actions/download-artifact@v6
# with:
# name: build-artifacts
# path: .
# - name: Validate build artifacts
# run: |
# if [ ! -d "apps/web/dist" ]; then
# echo "Error: Build artifacts missing - apps/web/dist not found"
# exit 1
# fi
# echo "Build artifacts validated successfully"
# - name: Cache Playwright browsers
# uses: actions/cache@v4
# with:
# path: ~/.cache/ms-playwright
# key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
# restore-keys: |
# playwright-${{ runner.os }}-
# - name: Install Playwright browsers
# run: pnpm exec playwright install --with-deps
# - name: Start preview server for E2E tests
# run: |
# cd apps/web
# # Use Python's built-in HTTP server to serve pre-built artifacts
# # This is much faster than pnpm preview which rebuilds everything
# python3 -m http.server 4173 --directory dist &
# echo "Static file server starting on port 4173..."
# # Wait for server to be ready (should be very quick with static server)
# timeout 10 bash -c 'until curl -s http://localhost:4173 > /dev/null; do sleep 1; done'
# echo "✅ Static file server is ready on http://localhost:4173"
# - name: Run E2E tests (shard ${{ matrix.shard }}/${{ env.E2E_SHARDS }})
# run: |
# cd apps/web
# # Run Playwright tests with JSON output to temporary file to avoid corruption
# # Use list reporter for console output, JSON reporter for clean results
# pnpm exec playwright test \
# --shard=${{ matrix.shard }}/${{ env.E2E_SHARDS }} \
# --reporter=list,json \
# 2>test-results/console-output.log \
# || {
# TEST_EXIT_CODE=$?
# echo "Playwright tests completed with exit code: $TEST_EXIT_CODE"
# # Copy the JSON results to expected location if it exists
# if [ -f "test-results/results.json" ]; then
# echo "✅ JSON results file created successfully"
# # Validate JSON integrity
# if jq empty "test-results/results.json" 2>/dev/null; then
# echo "✅ JSON results file is valid"
# else
# echo "⚠️ JSON results file is corrupted, showing first 200 characters:"
# head -200 "test-results/results.json" | cat -v
# fi
# else
# echo "⚠️ No JSON results file found"
# fi
# # Analyze the actual test results to determine if this is a real failure
# echo "🔍 Analyzing test results to distinguish between flaky passes and actual failures..."
# # Check for various possible result file locations
# RESULTS_FILE=""
# if [ -f "test-results/results.json" ]; then
# RESULTS_FILE="test-results/results.json"
# elif [ -f "blob-report/results.json" ]; then
# RESULTS_FILE="blob-report/results.json"
# else
# # Look for any JSON files that might contain results
# RESULTS_FILE=$(find test-results -name "*.json" -type f -exec file {} \; | grep "JSON" | cut -d: -f1 | head -1)
# fi
# if [ -n "$RESULTS_FILE" ] && [ -f "$RESULTS_FILE" ]; then
# echo "📄 Found results file: $RESULTS_FILE"
# # Try to parse the JSON results safely
# if jq empty "$RESULTS_FILE" 2>/dev/null; then
# echo "✅ Results file is valid JSON, analyzing..."
# # Get test summary from the results
# TOTAL_SPECS=$(jq -r '[.projects[]?.suites[]?.specs[]?] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# TOTAL_TESTS=$(jq -r '[.projects[]?.suites[]?.specs[]?.tests[]?] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# echo "📊 Test Statistics:"
# echo " Total test specs: $TOTAL_SPECS"
# echo " Total individual tests: $TOTAL_TESTS"
# if [ "$TOTAL_TESTS" -gt 0 ]; then
# # Count different test outcomes
# PASSED_TESTS=$(jq -r '[.projects[]?.suites[]?.specs[]?.tests[]? | select(.results[-1].status == "passed")] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# FAILED_TESTS=$(jq -r '[.projects[]?.suites[]?.specs[]?.tests[]? | select(.results[-1].status == "unexpected")] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# SKIPPED_TESTS=$(jq -r '[.projects[]?.suites[]?.specs[]?.tests[]? | select(.results[-1].status == "skipped")] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# FLAKY_TESTS=$(jq -r '[.projects[]?.suites[]?.specs[]?.tests[]? | select(.results | length > 1)] | length' "$RESULTS_FILE" 2>/dev/null || echo "0")
# echo " Passed: $PASSED_TESTS"
# echo " Failed: $FAILED_TESTS"
# echo " Skipped: $SKIPPED_TESTS"
# echo " Flaky: $FLAKY_TESTS"
# # Alternative: Check if JSON structure is what we expect
# # If not, fall back to console output analysis
# if [ "$TOTAL_TESTS" -gt 0 ]; then
# echo "✅ Found test data in JSON, analyzing..."
# # The key logic: if there are no failed tests, treat as success
# if [ "$FAILED_TESTS" -eq 0 ]; then
# echo "✅ All tests passed (some may have been flaky but eventually succeeded)."
# echo "🎉 Treating the test run as successful."
# exit 0
# else
# echo "❌ Found $FAILED_TESTS failed test(s). This represents actual test failures."
# echo "🔍 Failed tests:"
# jq -r '.projects[]?.suites[]?.specs[]?.tests[]? | select(.results[-1].status == "unexpected") | " - \(.title // \"Unnamed test\")"' "$RESULTS_FILE" 2>/dev/null || echo " (Could not extract test names)"
# echo "💾 Check the uploaded HTML report for detailed failure information."
# exit 1
# fi
# else
# echo "⚠️ JSON structure different than expected. Falling back to console output analysis..."
# # Look for pass/fail counts in console output if captured
# echo "🔍 Playwright exit code: $TEST_EXIT_CODE"
# echo "📊 Treating as failure due to parsing issues."
# exit 1
# fi
# else
# echo "⚠️ No tests were found in the JSON results. This might indicate:"
# echo " - Test discovery issues"
# echo " - Configuration problems"
# echo " - JSON reporter failure due to static caching (known issue)"
# echo " - Empty test shard (acceptable when manual tests are excluded)"
# echo "🔍 Playwright exit code: $TEST_EXIT_CODE"
# # Check console output for test results when JSON fails (static caching issue)
# # This handles the case where tests run successfully but JSON reporter fails
# echo "🔍 Analyzing console output to detect actual test execution..."
# # Look for test execution indicators in the captured console output log
# # Use a more permissive approach to detect if tests actually ran and passed
# if [ -f "test-results/console-output.log" ] && grep -q -E "(passed|flaky).*[0-9]+.*test" "test-results/console-output.log"; then
# echo "✅ Detected test execution in console output despite JSON reporting failure."
# echo "🎯 This appears to be a JSON reporter issue, not actual test failures."
# echo "🎉 Treating the test run as successful (tests passed but JSON reporter failed)."
# exit 0
# # Check if this is likely an empty shard due to manual test exclusion
# elif [ "$TEST_EXIT_CODE" -eq 0 ]; then
# echo "✅ Playwright exited successfully (code 0) with no tests."
# echo "🎯 This appears to be an empty shard due to manual test exclusion in CI."
# echo "🎉 Treating the test run as successful."
# exit 0
# else
# echo "❓ Tests may have run but failed to report results properly."
# echo "📊 This could be due to static caching interference with JSON reporting."
# echo "💾 Check the uploaded HTML report for actual test results."
# exit 1
# fi
# fi
# else
# echo "⚠️ Results file exists but is not valid JSON."
# echo "🔍 File content preview:"
# head -20 "$RESULTS_FILE" 2>/dev/null || echo "Could not read file"
# exit $TEST_EXIT_CODE
# fi
# else
# echo "⚠️ No test results file found. This indicates a test execution failure."
# echo "🔍 Searching for test-related files..."
# find test-results -type f -name "*.json" -o -name "*.txt" -o -name "*.log" 2>/dev/null | head -10 | sed 's/^/ /' || echo " (No files found)"
# echo "🔍 Playwright exit code: $TEST_EXIT_CODE"
# echo "💾 This likely indicates a test infrastructure problem rather than test failures."
# exit $TEST_EXIT_CODE
# fi
# }
# - name: Upload E2E artifacts
# uses: actions/upload-artifact@v5
# if: failure()
# with:
# name: playwright-report-shard-${{ matrix.shard }}
# path: apps/web/test-results/
# retention-days: 7
# Aggregate coverage and generate reports
coverage:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [validate]
if: always() && needs.validate.result == 'success'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download coverage artifacts
id: download-coverage
uses: actions/download-artifact@v6
continue-on-error: true
with:
name: coverage-all
path: coverage/
- name: Check coverage availability
id: coverage-check
run: |
if [ -d "coverage" ] && [ "$(ls -A coverage 2>/dev/null)" ]; then
echo "has-coverage=true" >> "${GITHUB_OUTPUT}"
echo "Coverage artifacts found"
else
echo "has-coverage=false" >> "${GITHUB_OUTPUT}"
echo "No coverage artifacts found - skipping coverage report"
fi
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Nx cache
uses: actions/cache@v4
with:
path: .nx
key: ${{ runner.os }}-nx-v3-${{ hashFiles('.github/workflows/ci.yml', 'nx.json', '**/project.json', 'pnpm-lock.yaml', 'tsconfig*.json') }}
restore-keys: |
${{ runner.os }}-nx-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate workspace coverage report
if: steps.coverage-check.outputs.has-coverage == 'true'
run: pnpm coverage:report
- name: Upload coverage to Codecov
if: steps.coverage-check.outputs.has-coverage == 'true'
uses: codecov/codecov-action@v4
with:
file: ./coverage/workspace-coverage.json
flags: unittests
name: codecov-umbrella
- name: Upload final coverage reports
if: steps.coverage-check.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v5
with:
name: final-coverage-reports
path: coverage/
retention-days: 30
- name: Coverage skipped summary
if: steps.coverage-check.outputs.has-coverage != 'true'
run: |
{
echo "## Coverage Report Skipped"
echo "No coverage artifacts were uploaded from the validate job."
echo "To generate coverage, run tests with the \`--coverage\` flag."
} >> "${GITHUB_STEP_SUMMARY}"
# Accessibility tests (pa11y via Nx)
test-accessibility:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [validate]
if: always() && needs.validate.result == 'success' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'a11y-check'))
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Nx cache
uses: actions/cache@v4
with:
path: .nx
key: ${{ runner.os }}-nx-v3-${{ hashFiles('.github/workflows/ci.yml', 'nx.json', '**/project.json', 'pnpm-lock.yaml', 'tsconfig*.json') }}
restore-keys: |
${{ runner.os }}-nx-v3-
- name: Download build artifacts
uses: actions/download-artifact@v6
with:
name: build-artifacts
path: .
- name: Install pa11y-ci
run: npm install -g pa11y-ci
- name: Run accessibility tests
run: pnpm nx test:accessibility web
continue-on-error: true
- name: Upload accessibility report
uses: actions/upload-artifact@v5
if: always()
with:
name: accessibility-report
path: pa11y-ci-report/
retention-days: 7
if-no-files-found: ignore
# Performance tests (Lighthouse via Nx)
test-performance:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [validate]
if: always() && needs.validate.result == 'success' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'perf-check'))
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Nx cache
uses: actions/cache@v4
with:
path: .nx
key: ${{ runner.os }}-nx-v3-${{ hashFiles('.github/workflows/ci.yml', 'nx.json', '**/project.json', 'pnpm-lock.yaml', 'tsconfig*.json') }}
restore-keys: |
${{ runner.os }}-nx-v3-
- name: Download build artifacts
uses: actions/download-artifact@v6
with:
name: build-artifacts
path: .
- name: Install Lighthouse CI
run: npm install -g @lhci/cli@0.13.x
- name: Run performance tests
run: pnpm nx test:performance web
continue-on-error: true
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
- name: Upload Lighthouse report
uses: actions/upload-artifact@v5
if: always()
with:
name: lighthouse-report
path: .lighthouseci/
retention-days: 7
if-no-files-found: ignore
# Route-based Smoke Tests - Fast validation that all routes from routeTree.gen.ts load successfully
smoke-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [validate]
if: always() && needs.validate.result == 'success'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
timeout-minutes: 5
run: pnpm install --frozen-lockfile
- name: Setup Nx cache
uses: actions/cache@v4
with:
path: .nx
key: ${{ runner.os }}-nx-v3-${{ hashFiles('.github/workflows/ci.yml', 'nx.json', '**/project.json', 'pnpm-lock.yaml', 'tsconfig*.json') }}
restore-keys: |
${{ runner.os }}-nx-v3-
- name: Download build artifacts
uses: actions/download-artifact@v6
with:
name: build-artifacts
path: build-artifacts-temp
- name: Restore build artifacts for smoke tests
run: |
echo "=== Restoring build artifacts for smoke tests ==="
# Check if artifacts are stored directly (without dist/ prefix) or under dist/
if [ -d "build-artifacts-temp/dist" ]; then
echo "✅ Found dist directory in temp artifacts (with dist/ prefix)"
mv build-artifacts-temp/dist ./dist
echo "✅ Moved dist directory to expected location"
elif [ -d "build-artifacts-temp/apps" ]; then
echo "✅ Found apps directory in temp artifacts (without dist/ prefix)"
mv build-artifacts-temp ./dist
echo "✅ Moved temp directory contents to dist/"
else
echo "❌ No valid build artifacts found in temp directory"
echo "=== Contents of temp directory ==="
find build-artifacts-temp -type f -o -type d | head -20
exit 1
fi
# Verify the restored structure
if [ -d "dist/apps/web" ]; then
echo "✅ dist/apps/web directory restored successfully"
find dist/apps/web -maxdepth 1 -type f | head -5
else
echo "❌ dist/apps/web directory not found after restore"
echo "=== Contents of dist directory ==="
find dist -type f -o -type d | head -20
exit 1
fi
# Clean up temp directory
rm -rf build-artifacts-temp
echo "✅ Temporary artifacts directory cleaned up"
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Start preview server for smoke tests
run: |
# Use built-in Node.js HTTP module to serve pre-built artifacts from dist/apps/web
# This is much faster than pnpm preview which rebuilds everything and doesn't require extra packages
node -e "
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
// Handle the root path and any path that doesn't start with /assets/
let requestPath = parsedUrl.pathname;
if (requestPath === '/') {
requestPath = '/index.html';
}
let filePath = path.join(process.env.GITHUB_WORKSPACE || '.', 'dist/apps/web', requestPath);
const extname = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon'
}[extname] || 'text/plain';
fs.readFile(filePath, (err, content) => {
if (err) {
console.log(\`File not found: \${filePath} for request: \${parsedUrl.pathname}\`);
res.writeHead(404);
res.end(\`File not found: \${parsedUrl.pathname}\`);
} else {
console.log(\`Serving file: \${filePath} for request: \${parsedUrl.pathname}\`);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
}
});
});
server.listen(4173, () => {
console.log('Static server ready on port 4173');
console.log('Serving files from:', path.join(process.env.GITHUB_WORKSPACE || '.', 'dist/apps/web'));
});
" &
echo "Static file server starting on port 4173..."
# Wait for server to be ready (should be very quick with static server)
timeout 10 bash -c 'until curl -s http://localhost:4173 > /dev/null; do sleep 1; done'
echo "✅ Static file server is ready on http://localhost:4173"
- name: Run route-based smoke tests
timeout-minutes: 8
run: |
cd apps/web
export NODE_ENV=production RUNNING_E2E=true BASE_URL=http://localhost:4173
pnpm exec playwright test src/test/e2e/page-smoke.e2e.test.ts --reporter=list
- name: Upload smoke test artifacts
if: always()
uses: actions/upload-artifact@v5
with:
name: smoke-test-results
path: |
apps/web/test-results/smoke-test-results.json
apps/web/test-results/smoke-test-report
retention-days: 7
if-no-files-found: ignore
# Deployment
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [validate, smoke-tests, security-audit]
if: always() && needs.validate.outputs.should-deploy == 'true' && github.ref == 'refs/heads/main' && needs.validate.result == 'success' && needs.security-audit.result == 'success'
environment:
name: production
url: https://mearman.github.io/BibGraph/
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Download build artifacts to temp directory
uses: actions/download-artifact@v6
with:
name: build-artifacts
path: build-artifacts-temp
- name: Install dependencies
timeout-minutes: 5
run: pnpm install --frozen-lockfile
- name: Restore build artifacts
run: |
echo "=== Restoring build artifacts from temp directory ==="
# Check if artifacts are stored directly (without dist/ prefix) or under dist/
if [ -d "build-artifacts-temp/dist" ]; then
echo "✅ Found dist directory in temp artifacts (with dist/ prefix)"
mv build-artifacts-temp/dist ./dist
echo "✅ Moved dist directory to expected location"
elif [ -d "build-artifacts-temp/apps" ]; then
echo "✅ Found apps directory in temp artifacts (without dist/ prefix)"
mv build-artifacts-temp ./dist
echo "✅ Moved temp directory contents to dist/"
else
echo "❌ No valid build artifacts found in temp directory"
echo "=== Contents of temp directory ==="
find build-artifacts-temp -type f -o -type d | head -20
exit 1
fi
# Verify the restored structure
if [ -d "dist/apps/web" ]; then
echo "✅ dist/apps/web directory restored successfully"
find dist/apps/web -maxdepth 1 -type f | head -5
else
echo "❌ dist/apps/web directory not found after restore"
echo "=== Contents of dist directory ==="
find dist -type f -o -type d | head -20
exit 1
fi
# Clean up temp directory
rm -rf build-artifacts-temp
echo "✅ Temporary artifacts directory cleaned up"
- name: Install PostHog CLI
run: npm install -g @posthog/cli
- name: Inject source map metadata
run: posthog-cli --host https://eu.posthog.com sourcemap inject --version ${{ github.sha }} --directory ./dist/apps/web
env:
POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }}
POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }}
- name: Upload source maps to PostHog
run: posthog-cli --host https://eu.posthog.com sourcemap upload --directory ./dist/apps/web --delete-after
env:
POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }}
POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }}
- name: Upload build artifacts
uses: actions/upload-pages-artifact@v4
with:
path: dist/apps/web
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
- name: Run smoke tests
run: |
sleep 30
curl -f ${{ steps.deployment.outputs.page_url }} || exit 1
# Post-deployment E2E tests against live GitHub Pages
post-deploy-e2e:
runs-on: ubuntu-latest
timeout-minutes: 25
needs: deploy
if: false # Disabled - full E2E suite takes 20-25 minutes and times out
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps
- name: Wait for GitHub Pages propagation
run: |
echo "Waiting for GitHub Pages to be fully propagated..."
sleep 60
- name: Run comprehensive E2E tests against live site
env:
E2E_BASE_URL: "https://mearman.github.io/BibGraph/"
E2E_FULL_SUITE: "true"
CI: "true"
run: |
echo "Running E2E tests against live GitHub Pages site..."
pnpm nx test:e2e web
- name: Upload post-deployment E2E artifacts
uses: actions/upload-artifact@v5
if: failure()
with:
name: post-deploy-e2e-report
path: test-results/playwright-report/
retention-days: 7
- name: Create issue on failure
if: failure()
uses: actions/github-script@v8
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🚨 Post-Deployment E2E Test Failure',
body: `Post-deployment E2E tests failed against the live GitHub Pages site.
**Deployment URL**: https://mearman.github.io/BibGraph/
**Workflow Run**: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}
**Commit**: ${context.sha}
Please investigate the deployment and rollback if necessary.
Steps taken:
- ✅ Build and test completed
- ✅ Deployment to GitHub Pages completed
- ❌ Post-deployment E2E tests failed
The rollback job should trigger automatically to restore the previous working version.`,
labels: ['bug', 'ci/cd', 'urgent']
})
# Release automation
release:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [validate, smoke-tests, security-audit, deploy]
if: always() && needs.validate.result == 'success' && needs.security-audit.result == 'success' && needs.deploy.result == 'success' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Git
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
- name: Run semantic-release
run: |
npx semantic-release || {
echo "Semantic-release failed or no changes to release"
echo "This is normal if no conventional commits warrant a release"
exit 0
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Automatic rollback on post-deployment E2E failure
rollback:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: post-deploy-e2e
if: failure() && needs.post-deploy-e2e.result == 'failure' && github.ref == 'refs/heads/main'
permissions:
contents: write
pages: write
id-token: write
issues: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get previous successful deployment
id: previous-deployment
run: |
echo "Finding previous successful GitHub Pages deployment..."
# Get the list of Pages deployments
deployments=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/pages/deployments)
# Find the most recent successful deployment (excluding current failed one)
previous_deployment_id=$(echo "$deployments" | jq '[.[] | select(.status == "succeeded" and .updated_at < "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'")] | sort_by(.updated_at) | reverse | .[0].id')
if [ "$previous_deployment_id" = "null" ] || [ -z "$previous_deployment_id" ]; then
echo "No previous successful deployment found"
echo "deployment_id=" >> "${GITHUB_OUTPUT}"
exit 1
fi
echo "deployment_id=$previous_deployment_id" >> "${GITHUB_OUTPUT}"
echo "Previous successful deployment ID: $previous_deployment_id"
- name: Rollback to previous deployment
if: steps.previous-deployment.outputs.deployment_id != ''
run: |
echo "Rolling back to deployment ID: ${{ steps.previous-deployment.outputs.deployment_id }}"
# Cancel current deployment if still active
current_deployments=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/pages/deployments)
current_id=$(echo "$current_deployments" | jq '.[0].id')
if [ "$current_id" != "null" ] && [ -n "$current_id" ]; then
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/pages/deployments/$current_id/cancel"
echo "Cancelled current deployment: $current_id"
fi
# Promote previous successful deployment
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/pages/deployments/${{ steps.previous-deployment.outputs.deployment_id }}/promote"
echo "Rollback initiated successfully"
- name: Create rollback issue
if: steps.previous-deployment.outputs.deployment_id != ''
uses: actions/github-script@v8
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🔄 Automatic Rollback Completed',
body: `The GitHub Pages deployment has been automatically rolled back due to post-deployment E2E test failures.
**Previous Deployment ID**: ${{ steps.previous-deployment.outputs.deployment_id }}
**Live Site**: https://mearman.github.io/BibGraph/
**Workflow Run**: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}
**Failed Commit**: ${context.sha}
**What happened:**
- ✅ Build and test completed successfully
- ✅ Deployment to GitHub Pages completed
- ❌ Post-deployment E2E tests failed
- 🔄 Automatic rollback to previous successful deployment completed
**Next steps:**
1. Investigate the E2E test failures in the workflow run
2. Review the deployment artifacts and test reports
3. Fix the issues and retry deployment
4. Monitor the next deployment's post-deployment tests
The site should now be restored to the previous working version.`,
labels: ['ci/cd', 'rollback', 'resolved']
})
- name: Wait for rollback propagation
if: steps.previous-deployment.outputs.deployment_id != ''
run: |
echo "Waiting for rollback to propagate..."
sleep 30
# Verify rollback is working
max_attempts=10
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking rollback status (attempt $attempt/$max_attempts)..."
if curl -f -s "https://mearman.github.io/BibGraph/" > /dev/null; then
echo "✅ Rollback verified - site is accessible"
break
fi
if [ $attempt -eq $max_attempts ]; then
echo "⚠️ Rollback verification failed after $max_attempts attempts"
exit 1
fi
sleep 30
attempt=$((attempt + 1))
done
# Results and notifications
results:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [validate, security-audit, coverage, test-accessibility, test-performance, deploy, post-deploy-e2e, release, rollback]
if: always()
steps:
- name: Download coverage reports
if: needs.coverage.result == 'success'
uses: actions/download-artifact@v6
continue-on-error: true
with:
name: final-coverage-reports
path: coverage/
- name: Generate results summary
run: |
{
echo "# 📊 CI/CD Pipeline Results"
echo ""
echo "| Job | Status | Details |"
echo "|-----|--------|---------|"
echo "| ✅ Validate | ${{ needs.validate.result }} | Build + typecheck + lint + all tests |"
echo "| 🔒 Security Audit | ${{ needs.security-audit.result }} | Vulnerability scanning |"
echo "| 🚫 E2E Tests | DISABLED | Full E2E suite (disabled due to CI issues) |"
echo "| 📈 Coverage | ${{ needs.coverage.result }} | Workspace coverage report |"
echo "| ♿ Accessibility | ${{ needs.test-accessibility.result }} | pa11y WCAG 2.1 AA |"
echo "| ⚡ Performance | ${{ needs.test-performance.result }} | Lighthouse CI |"
echo "| 🚀 Deploy | ${{ needs.deploy.result }} | ${{ needs.validate.outputs.should-deploy == 'true' && 'GitHub Pages (after all tests pass)' || 'Skipped' }} |"
echo "| 🎯 Post-Deploy E2E | ${{ needs.post-deploy-e2e.result }} | ${{ needs.post-deploy-e2e.result == 'success' && 'Live site verification passed' || (needs.post-deploy-e2e.result == 'skipped' && 'Not run' || 'Live site verification failed') }} |"
echo "| 🔄 Rollback | ${{ needs.rollback.result }} | ${{ needs.rollback.result == 'success' && 'Automatic rollback completed' || (needs.rollback.result == 'skipped' && 'Not needed' || 'Rollback failed') }} |"
echo "| 📦 Release | ${{ needs.release.result }} | ${{ needs.release.result == 'success' && 'Release created successfully' || (needs.release.result == 'skipped' && 'Not created (failed post-deploy E2E)' || 'Release failed') }} |"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Coverage summary
if: needs.coverage.result == 'success'
run: |
if [ -f coverage/workspace-coverage.json ]; then
OVERALL=$(jq -r '.overall' coverage/workspace-coverage.json)
LINES=$(jq -r '.totals.lines.percentage' coverage/workspace-coverage.json)
FUNCTIONS=$(jq -r '.totals.functions.percentage' coverage/workspace-coverage.json)
{
echo "## 📈 Coverage Summary"
echo ""
echo "- **Overall Coverage**: ${OVERALL}%"
echo "- **Lines Coverage**: ${LINES}%"
echo "- **Functions Coverage**: ${FUNCTIONS}%"
} >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Notification on failure
if: failure()
run: |
echo "❌ Pipeline failed! Check the individual job logs for details." >> "${GITHUB_STEP_SUMMARY}"
exit 1
- name: Success notification
if: success()
run: |
echo "🎉 All checks passed! Ready for merge/deployment." >> "${GITHUB_STEP_SUMMARY}"