Add claim audit event timeline (#1084) #1544
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: Quality Gate | |
| # Comprehensive quality control pipeline that runs smoke tests and E2E tests | |
| # on a schedule and on-demand. Captures timing metrics and test results as | |
| # artifacts for trend analysis. | |
| # | |
| # Design principles: | |
| # - Smoke tests run on every PR and push to main (fast feedback) | |
| # - E2E tests run nightly and on release branches (thorough validation) | |
| # - All results are published as artifacts with consistent schema | |
| # - New tests are added by creating new test projects in tests/ and they | |
| # are automatically discovered | |
| # - Metrics (pass/fail, duration, timestamps) feed into trend dashboards | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| on: | |
| push: | |
| branches: [main, 'release/*'] | |
| pull_request: | |
| branches: [main, 'release/*'] | |
| schedule: | |
| # Nightly at 4 AM UTC — full suite including E2E | |
| - cron: '0 4 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| run_e2e: | |
| description: 'Run E2E tests (requires Docker Compose services)' | |
| type: boolean | |
| default: false | |
| run_load: | |
| description: 'Run load tests' | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: quality-gate-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 1. Smoke Tests — fast structural & API validation | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| smoke-tests: | |
| name: Smoke Tests | |
| runs-on: ubuntu-latest | |
| outputs: | |
| test_count: ${{ steps.results.outputs.test_count }} | |
| pass_count: ${{ steps.results.outputs.pass_count }} | |
| fail_count: ${{ steps.results.outputs.fail_count }} | |
| duration_ms: ${{ steps.results.outputs.duration_ms }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v6 | |
| with: | |
| dotnet-version: '8.0.x' | |
| - name: Discover smoke test projects | |
| id: discover | |
| run: | | |
| set -euo pipefail | |
| # Smoke tests are discovered by filename convention: | |
| # - *Smoke*.csproj or *smoke*.csproj in tests/ | |
| # To add new smoke tests, create a project with "Smoke" in the name. | |
| mapfile -t SMOKE_PROJECTS < <( | |
| find tests -type f \( -name '*Smoke*.csproj' -o -name '*smoke*.csproj' \) 2>/dev/null | sort | |
| ) | |
| # Also include the CapitationService tests which contain smoke test methods | |
| for proj in tests/CloudHealthOffice.CapitationService.Tests/*.csproj; do | |
| [ -f "$proj" ] && SMOKE_PROJECTS+=("$proj") | |
| done | |
| echo "count=${#SMOKE_PROJECTS[@]}" >> "$GITHUB_OUTPUT" | |
| printf '%s\n' "${SMOKE_PROJECTS[@]}" > /tmp/smoke-projects.txt | |
| - name: Run smoke tests | |
| id: run | |
| run: | | |
| set -euo pipefail | |
| START_MS=$(date +%s%3N) | |
| mkdir -p SmokeResults | |
| FAILED=0 | |
| while IFS= read -r PROJECT; do | |
| [ -z "$PROJECT" ] && continue | |
| NAME=$(basename "$PROJECT" .csproj) | |
| echo "::group::Smoke: $NAME" | |
| dotnet restore "$PROJECT" 2>&1 || { FAILED=$((FAILED+1)); echo "::endgroup::"; continue; } | |
| dotnet build "$PROJECT" --no-restore 2>&1 || { FAILED=$((FAILED+1)); echo "::endgroup::"; continue; } | |
| dotnet test "$PROJECT" --no-build \ | |
| --logger "trx;LogFileName=${NAME}-smoke.trx" \ | |
| --results-directory ./SmokeResults \ | |
| --verbosity normal 2>&1 || FAILED=$((FAILED+1)) | |
| echo "::endgroup::" | |
| done < /tmp/smoke-projects.txt | |
| END_MS=$(date +%s%3N) | |
| echo "duration_ms=$((END_MS - START_MS))" >> "$GITHUB_OUTPUT" | |
| echo "failed=$FAILED" >> "$GITHUB_OUTPUT" | |
| - name: Parse smoke results | |
| id: results | |
| run: | | |
| TOTAL=0; PASSED=0; FAILED=0 | |
| for trx in SmokeResults/*.trx; do | |
| [ -f "$trx" ] || continue | |
| t=$(grep -oP 'total="\K[0-9]+' "$trx" | head -1) || t=0 | |
| p=$(grep -oP 'passed="\K[0-9]+' "$trx" | head -1) || p=0 | |
| f=$(grep -oP 'failed="\K[0-9]+' "$trx" | head -1) || f=0 | |
| TOTAL=$((TOTAL + t)); PASSED=$((PASSED + p)); FAILED=$((FAILED + f)) | |
| done | |
| { | |
| echo "test_count=$TOTAL" | |
| echo "pass_count=$PASSED" | |
| echo "fail_count=$FAILED" | |
| echo "duration_ms=${{ steps.run.outputs.duration_ms }}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Upload smoke results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: smoke-test-results | |
| path: SmokeResults/ | |
| retention-days: 90 | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 2. Sanity Checks — repository structure & infrastructure validation | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| sanity-checks: | |
| name: Sanity Checks | |
| runs-on: ubuntu-latest | |
| outputs: | |
| check_count: ${{ steps.results.outputs.check_count }} | |
| pass_count: ${{ steps.results.outputs.pass_count }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Run structural validation | |
| id: results | |
| run: | | |
| set -euo pipefail | |
| CHECKS=0; PASSED=0 | |
| # Required directories | |
| for dir in infrastructure scripts .github/workflows src/services tests; do | |
| CHECKS=$((CHECKS + 1)) | |
| if [ -d "$dir" ]; then | |
| PASSED=$((PASSED + 1)) | |
| echo "PASS: directory $dir exists" | |
| else | |
| echo "FAIL: directory $dir missing" | |
| fi | |
| done | |
| # Required files | |
| for file in README.md SECURITY.md CONTRIBUTING.md; do | |
| CHECKS=$((CHECKS + 1)) | |
| if [ -f "$file" ]; then | |
| PASSED=$((PASSED + 1)) | |
| echo "PASS: file $file exists" | |
| else | |
| echo "FAIL: file $file missing" | |
| fi | |
| done | |
| # Test projects exist | |
| CHECKS=$((CHECKS + 1)) | |
| TEST_PROJ_COUNT=$(find tests -name '*.Tests.csproj' | wc -l) | |
| if [ "$TEST_PROJ_COUNT" -gt 0 ]; then | |
| PASSED=$((PASSED + 1)) | |
| echo "PASS: $TEST_PROJ_COUNT .NET test projects found" | |
| else | |
| echo "FAIL: no .NET test projects" | |
| fi | |
| # Jest config exists | |
| CHECKS=$((CHECKS + 1)) | |
| if [ -f jest.config.js ]; then | |
| PASSED=$((PASSED + 1)) | |
| echo "PASS: jest.config.js exists" | |
| else | |
| echo "FAIL: jest.config.js missing" | |
| fi | |
| # Docker Compose files | |
| CHECKS=$((CHECKS + 1)) | |
| if [ -f docker-compose.yml ]; then | |
| PASSED=$((PASSED + 1)) | |
| echo "PASS: docker-compose.yml exists" | |
| else | |
| echo "FAIL: docker-compose.yml missing" | |
| fi | |
| echo "check_count=$CHECKS" >> "$GITHUB_OUTPUT" | |
| echo "pass_count=$PASSED" >> "$GITHUB_OUTPUT" | |
| if [ "$PASSED" -lt "$CHECKS" ]; then | |
| echo "::warning::$((CHECKS - PASSED)) sanity checks failed" | |
| fi | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 3. E2E Tests — full system integration (nightly + manual) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| e2e-tests: | |
| name: E2E Tests | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'schedule' || | |
| github.event.inputs.run_e2e == 'true' || | |
| (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/')) | |
| outputs: | |
| test_count: ${{ steps.results.outputs.test_count }} | |
| pass_count: ${{ steps.results.outputs.pass_count }} | |
| fail_count: ${{ steps.results.outputs.fail_count }} | |
| duration_ms: ${{ steps.results.outputs.duration_ms }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v6 | |
| with: | |
| dotnet-version: '8.0.x' | |
| - name: Start services with Docker Compose | |
| run: | | |
| docker compose -f docker-compose.yml up -d \ | |
| claims-service benefit-plan-service payment-service \ | |
| mongo redis | |
| echo "Waiting for services to become healthy..." | |
| sleep 30 | |
| - name: Wait for service health | |
| run: | | |
| for svc in "localhost:5001/health" "localhost:5002/health" "localhost:5003/health"; do | |
| echo "Checking $svc..." | |
| for i in $(seq 1 30); do | |
| if curl -sf "http://$svc" >/dev/null 2>&1; then | |
| echo " $svc is healthy" | |
| break | |
| fi | |
| [ "$i" -eq 30 ] && echo "::warning::$svc did not become healthy within 60s" | |
| sleep 2 | |
| done | |
| done | |
| - name: Run E2E tests | |
| id: run | |
| run: | | |
| set -euo pipefail | |
| START_MS=$(date +%s%3N) | |
| mkdir -p E2EResults | |
| dotnet restore tests/CloudHealthOffice.E2E/CloudHealthOffice.E2E.csproj | |
| dotnet build tests/CloudHealthOffice.E2E/CloudHealthOffice.E2E.csproj --no-restore | |
| dotnet test tests/CloudHealthOffice.E2E/CloudHealthOffice.E2E.csproj --no-build \ | |
| --logger "trx;LogFileName=e2e-results.trx" \ | |
| --results-directory ./E2EResults \ | |
| --verbosity normal 2>&1 || true | |
| END_MS=$(date +%s%3N) | |
| echo "duration_ms=$((END_MS - START_MS))" >> "$GITHUB_OUTPUT" | |
| - name: Parse E2E results | |
| id: results | |
| run: | | |
| TOTAL=0; PASSED=0; FAILED=0 | |
| for trx in E2EResults/*.trx; do | |
| [ -f "$trx" ] || continue | |
| t=$(grep -oP 'total="\K[0-9]+' "$trx" | head -1) || t=0 | |
| p=$(grep -oP 'passed="\K[0-9]+' "$trx" | head -1) || p=0 | |
| f=$(grep -oP 'failed="\K[0-9]+' "$trx" | head -1) || f=0 | |
| TOTAL=$((TOTAL + t)); PASSED=$((PASSED + p)); FAILED=$((FAILED + f)) | |
| done | |
| { | |
| echo "test_count=$TOTAL" | |
| echo "pass_count=$PASSED" | |
| echo "fail_count=$FAILED" | |
| echo "duration_ms=${{ steps.run.outputs.duration_ms }}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Capture service logs on failure | |
| if: failure() | |
| run: | | |
| mkdir -p E2EResults/logs | |
| docker compose logs --tail=200 > E2EResults/logs/docker-compose.log 2>&1 || true | |
| - name: Tear down services | |
| if: always() | |
| run: docker compose down -v 2>/dev/null || true | |
| - name: Upload E2E results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: e2e-test-results | |
| path: E2EResults/ | |
| retention-days: 90 | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 4. Load Tests (on-demand or nightly) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| load-tests: | |
| name: Load Tests | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event.inputs.run_load == 'true' || | |
| github.event_name == 'schedule' | |
| outputs: | |
| scenarios_run: ${{ steps.results.outputs.scenarios_run }} | |
| duration_ms: ${{ steps.results.outputs.duration_ms }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v6 | |
| with: | |
| dotnet-version: '8.0.x' | |
| - name: Start services | |
| run: | | |
| docker compose -f docker-compose.yml up -d \ | |
| claims-service payment-service mongo redis | |
| sleep 30 | |
| - name: Run load tests | |
| id: run | |
| run: | | |
| START_MS=$(date +%s%3N) | |
| mkdir -p LoadResults | |
| dotnet restore tests/CloudHealthOffice.LoadTests/CloudHealthOffice.LoadTests.csproj 2>&1 || true | |
| dotnet build tests/CloudHealthOffice.LoadTests/CloudHealthOffice.LoadTests.csproj --no-restore 2>&1 || true | |
| dotnet test tests/CloudHealthOffice.LoadTests/CloudHealthOffice.LoadTests.csproj --no-build \ | |
| --logger "trx;LogFileName=load-results.trx" \ | |
| --results-directory ./LoadResults \ | |
| --verbosity normal 2>&1 || true | |
| END_MS=$(date +%s%3N) | |
| echo "duration_ms=$((END_MS - START_MS))" >> "$GITHUB_OUTPUT" | |
| - name: Parse load test results | |
| id: results | |
| run: | | |
| SCENARIOS=0 | |
| for trx in LoadResults/*.trx; do | |
| [ -f "$trx" ] || continue | |
| t=$(grep -oP 'total="\K[0-9]+' "$trx" | head -1) || t=0 | |
| SCENARIOS=$((SCENARIOS + t)) | |
| done | |
| echo "scenarios_run=$SCENARIOS" >> "$GITHUB_OUTPUT" | |
| echo "duration_ms=${{ steps.run.outputs.duration_ms }}" >> "$GITHUB_OUTPUT" | |
| - name: Tear down services | |
| if: always() | |
| run: docker compose down -v 2>/dev/null || true | |
| - name: Upload load test results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: load-test-results | |
| path: LoadResults/ | |
| retention-days: 90 | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 5. Quality Gate Summary | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| quality-summary: | |
| name: Quality Gate Summary | |
| runs-on: ubuntu-latest | |
| needs: [smoke-tests, sanity-checks, e2e-tests, load-tests] | |
| if: always() | |
| steps: | |
| - name: Generate quality report | |
| run: | | |
| TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') | |
| SHA="${GITHUB_SHA:-unknown}" | |
| SMOKE_TOTAL=${{ needs.smoke-tests.outputs.test_count || 0 }} | |
| SMOKE_PASS=${{ needs.smoke-tests.outputs.pass_count || 0 }} | |
| SMOKE_FAIL=${{ needs.smoke-tests.outputs.fail_count || 0 }} | |
| SMOKE_DURATION=${{ needs.smoke-tests.outputs.duration_ms || 0 }} | |
| SANITY_TOTAL=${{ needs.sanity-checks.outputs.check_count || 0 }} | |
| SANITY_PASS=${{ needs.sanity-checks.outputs.pass_count || 0 }} | |
| E2E_TOTAL=${{ needs.e2e-tests.outputs.test_count || 0 }} | |
| E2E_PASS=${{ needs.e2e-tests.outputs.pass_count || 0 }} | |
| E2E_FAIL=${{ needs.e2e-tests.outputs.fail_count || 0 }} | |
| E2E_DURATION=${{ needs.e2e-tests.outputs.duration_ms || 0 }} | |
| LOAD_SCENARIOS=${{ needs.load-tests.outputs.scenarios_run || 0 }} | |
| LOAD_DURATION=${{ needs.load-tests.outputs.duration_ms || 0 }} | |
| cat > quality-report.json <<EOF | |
| { | |
| "generated_at": "$TIMESTAMP", | |
| "git_sha": "$SHA", | |
| "smoke_tests": { | |
| "total": $SMOKE_TOTAL, | |
| "passed": $SMOKE_PASS, | |
| "failed": $SMOKE_FAIL, | |
| "duration_ms": $SMOKE_DURATION | |
| }, | |
| "sanity_checks": { | |
| "total": $SANITY_TOTAL, | |
| "passed": $SANITY_PASS | |
| }, | |
| "e2e_tests": { | |
| "total": $E2E_TOTAL, | |
| "passed": $E2E_PASS, | |
| "failed": $E2E_FAIL, | |
| "duration_ms": $E2E_DURATION | |
| }, | |
| "load_tests": { | |
| "scenarios": $LOAD_SCENARIOS, | |
| "duration_ms": $LOAD_DURATION | |
| } | |
| } | |
| EOF | |
| { | |
| echo "## Quality Gate Report" | |
| echo "" | |
| echo "| Category | Total | Passed | Failed | Duration |" | |
| echo "|----------|-------|--------|--------|----------|" | |
| echo "| Smoke Tests | $SMOKE_TOTAL | $SMOKE_PASS | $SMOKE_FAIL | ${SMOKE_DURATION}ms |" | |
| echo "| Sanity Checks | $SANITY_TOTAL | $SANITY_PASS | $((SANITY_TOTAL - SANITY_PASS)) | — |" | |
| echo "| E2E Tests | $E2E_TOTAL | $E2E_PASS | $E2E_FAIL | ${E2E_DURATION}ms |" | |
| echo "| Load Tests | $LOAD_SCENARIOS scenarios | — | — | ${LOAD_DURATION}ms |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Gate: fail if any smoke tests failed | |
| if [ "$SMOKE_FAIL" -gt 0 ]; then | |
| { | |
| echo "" | |
| echo "> **Quality Gate FAILED**: $SMOKE_FAIL smoke test(s) failed" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| { | |
| echo "" | |
| echo "> **Quality Gate PASSED**" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload quality report | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: quality-report | |
| path: quality-report.json | |
| retention-days: 365 |