Skip to content

Build and Test

Build and Test #615

Workflow file for this run

name: Build and Test
on:
push: # Trigger on every commit to any branch
pull_request:
branches: [ main, develop ]
schedule:
# Run nightly tests at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
run_performance_tests:
description: 'Run performance benchmarks'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
checks: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
NUGET_XMLDOC_MODE: skip
jobs:
# Matrix job for unit tests across platforms and frameworks
unit-tests:
name: Unit Tests (${{ matrix.os }}, ${{ matrix.framework }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
framework: [net8.0, net9.0, net10.0]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x
dotnet-quality: 'ga'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props', 'global.json', 'Directory.Build.*') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore --verbosity minimal
- name: Build solution
run: dotnet build --configuration Release --no-restore --verbosity minimal
- name: Run unit tests
shell: bash
run: |
dotnet test \
--configuration Release \
--no-build \
--verbosity minimal \
--framework ${{ matrix.framework }} \
--filter "Category=Unit" \
--collect:"XPlat Code Coverage" \
--results-directory ./test-results \
--logger "trx;LogFileName=unit-tests-${{ matrix.os }}-${{ matrix.framework }}.trx" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
- name: Upload test results
uses: actions/upload-artifact@v5
if: always()
with:
name: test-results-${{ matrix.os }}-${{ matrix.framework }}
path: |
./test-results/**/*.trx
./test-results/**/*.xml
retention-days: 7
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
if: matrix.os == 'ubuntu-latest' && matrix.framework == 'net8.0'
with:
directory: ./test-results
flags: unit-tests
fail_ci_if_error: false
# Performance regression detection
performance-regression:
name: Performance Regression Analysis
needs: unit-tests
if: always() && (github.event_name == 'schedule' || github.event.inputs.run_performance_tests == 'true')
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build benchmarks
run: dotnet build tests/HeroMessaging.Benchmarks --configuration Release --no-restore
- name: Download baseline benchmarks
uses: actions/download-artifact@v6
continue-on-error: true # Baseline may not exist on first run
with:
name: performance-baseline
path: ./benchmark-baseline
- name: Run performance benchmarks
run: |
dotnet run \
--project tests/HeroMessaging.Benchmarks \
--configuration Release \
-- --exporters json \
--artifacts ./benchmark-results \
--job short
- name: Analyze performance regression
continue-on-error: true # Informational only - don't fail CI
run: |
dotnet run \
--project tests/HeroMessaging.Benchmarks \
--configuration Release \
-- --analyze-regression \
--current ./benchmark-results \
--baseline ./benchmark-baseline \
--threshold 10
- name: Upload current benchmarks as baseline
uses: actions/upload-artifact@v5
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with:
name: performance-baseline
path: ./benchmark-results/**/*.json
retention-days: 90
- name: Comment performance results
uses: actions/github-script@v8
if: github.event_name == 'pull_request'
with:
script: |
const fs = require('fs');
const path = './benchmark-results/regression-report.md';
if (fs.existsSync(path)) {
const report = fs.readFileSync(path, 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Performance Analysis\n\n${report}`
});
}
# Quality gates and final validation
quality-gates:
name: Quality Gates & Validation
needs: [unit-tests]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download all test results
uses: actions/download-artifact@v6
with:
path: ./all-test-results
continue-on-error: true
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.0.x
- name: Install ReportGenerator
run: dotnet tool install --global dotnet-reportgenerator-globaltool
- name: Generate combined coverage report
continue-on-error: true
run: |
# Check if any coverage files exist
if find ./all-test-results -name "coverage.cobertura.xml" -type f | grep -q .; then
echo "Found coverage files, generating report..."
echo ""
echo "Coverage filters:"
echo " ✓ Including: Production code from src/"
echo " ✗ Excluding: System.*, Microsoft.* framework classes"
echo " ✗ Excluding: *.Tests, *Benchmarks, *.Examples, *.Demo assemblies"
echo " ✗ Excluding: Exception classes (*Exception)"
echo ""
echo "ℹ️ Note: Simple DTOs, records, and value objects are included in coverage."
echo " The 80% threshold accounts for both logic and data structures."
echo ""
reportgenerator \
-reports:"./all-test-results/**/coverage.cobertura.xml" \
-targetdir:"./coverage-report" \
-reporttypes:"Html;Badges;TextSummary" \
-classfilters:"-System.*;-Microsoft.*;-*Exception;-*Exception`*" \
-assemblyfilters:"-*.Tests;-*.Tests.*;-*Benchmarks;-*Benchmarks.*;-*.Examples;-*.Examples.*;-*.Demo;-*.Demo.*"
else
echo "No coverage files found, skipping report generation"
mkdir -p ./coverage-report
echo "Line coverage: 0%" > ./coverage-report/Summary.txt
fi
- name: Read coverage summary
id: coverage
run: |
if [ -f "./coverage-report/Summary.txt" ]; then
COVERAGE=$(grep "Line coverage:" ./coverage-report/Summary.txt | grep -o '[0-9.]*%' | head -1 | sed 's/%//')
echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT
echo "Coverage: $COVERAGE%"
else
echo "coverage=0" >> $GITHUB_OUTPUT
echo "No coverage data found"
fi
- name: Upload coverage report
uses: actions/upload-artifact@v5
with:
name: coverage-report
path: ./coverage-report/**/*
retention-days: 30
- name: Check quality gates
run: |
echo "Checking quality gates..."
# Check test results first
UNIT_TESTS_PASSED="${{ needs.unit-tests.result }}"
echo "Unit tests: $UNIT_TESTS_PASSED"
echo "ℹ️ Integration tests run in separate workflow"
if [[ "$UNIT_TESTS_PASSED" != "success" && "$UNIT_TESTS_PASSED" != "skipped" ]]; then
echo "❌ Unit tests failed"
exit 1
fi
# Coverage threshold check (constitutional requirement: 80% minimum)
COVERAGE=${{ steps.coverage.outputs.coverage }}
if [[ "$UNIT_TESTS_PASSED" == "skipped" ]]; then
echo "⚠️ No coverage data (no unit tests ran)"
else
echo "📊 Production Code Coverage (Unit Tests): $COVERAGE%"
echo "📋 Threshold: 80% (per CLAUDE.md constitutional requirement)"
echo "ℹ️ Measuring: src/ assemblies only (excluding tests, benchmarks, examples)"
echo ""
# Check if coverage meets 80% threshold
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "❌ Coverage below 80% threshold: $COVERAGE%"
echo ""
echo "Constitutional requirement: 80% minimum coverage (100% for public APIs)"
echo "Note: This measures unit test coverage of production code only."
echo "Integration tests run separately in the 'Integration Tests' workflow."
exit 1
else
echo "✅ Coverage meets 80% threshold"
fi
fi
echo "✅ All quality gates passed"
- name: Update status badges
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
echo "Updating status badges..."
# This would typically update README badges or external services
# Build release packages for main branch (used by create-release workflow)
build-release-packages:
name: Build Release Packages
needs: quality-gates
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --configuration Release --no-restore
- name: Generate build number
id: build-number
run: |
# Extract base version from project file with fallback
BASE_VERSION=$(grep -oP '<Version>\K[^<]+' src/HeroMessaging/HeroMessaging.csproj 2>/dev/null || \
grep '<Version>' src/HeroMessaging/HeroMessaging.csproj 2>/dev/null | sed -n 's/.*<Version>\(.*\)<\/Version>.*/\1/p' || \
echo "0.0.0")
# Extract and sanitize branch name (replace / with -)
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/\//-/g')
# Build number components
BUILD_DATE=$(date -u +%Y%m%d)
RUN_NUMBER=${{ github.run_number }}
SHORT_SHA=${GITHUB_SHA:0:7}
# Construct build number: branch-date.run.hash
BUILD_NUMBER="${BRANCH_NAME}-${BUILD_DATE}.${RUN_NUMBER}.${SHORT_SHA}"
FULL_VERSION="${BASE_VERSION}-ci.${BUILD_NUMBER}"
echo "build-number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
echo "full-version=${FULL_VERSION}" >> $GITHUB_OUTPUT
echo "📋 Build Information:"
echo " Base Version: ${BASE_VERSION}"
echo " Branch: ${BRANCH_NAME}"
echo " Date: ${BUILD_DATE}"
echo " Run Number: ${RUN_NUMBER}"
echo " Git Hash: ${SHORT_SHA}"
echo " Build Number: ${BUILD_NUMBER}"
echo " Full Version: ${FULL_VERSION}"
- name: Pack NuGet packages
run: |
mkdir -p ./release-packages
dotnet pack src/HeroMessaging/HeroMessaging.csproj \
--configuration Release \
--no-build \
--output ./release-packages \
-p:PackageVersion=${{ steps.build-number.outputs.full-version }} \
-p:IncludeSymbols=true \
-p:SymbolPackageFormat=snupkg
dotnet pack src/HeroMessaging.Abstractions/HeroMessaging.Abstractions.csproj \
--configuration Release \
--no-build \
--output ./release-packages \
-p:PackageVersion=${{ steps.build-number.outputs.full-version }} \
-p:IncludeSymbols=true \
-p:SymbolPackageFormat=snupkg
- name: Generate SBOM (Software Bill of Materials)
run: |
echo "📋 Generating SBOM for supply chain security..."
# Install Microsoft SBOM tool
dotnet tool install --global Microsoft.Sbom.DotNetTool || dotnet tool update --global Microsoft.Sbom.DotNetTool
# Generate SBOM for the main project
sbom-tool generate \
-b ./src/HeroMessaging \
-bc ./src/HeroMessaging \
-pn HeroMessaging \
-pv ${{ steps.build-number.outputs.full-version }} \
-ps "KoalaFacts" \
-nsb https://github.com/KoalaFacts/HeroMessaging \
-m ./release-packages/sbom
# Generate SBOM for abstractions
sbom-tool generate \
-b ./src/HeroMessaging.Abstractions \
-bc ./src/HeroMessaging.Abstractions \
-pn HeroMessaging.Abstractions \
-pv ${{ steps.build-number.outputs.full-version }} \
-ps "KoalaFacts" \
-nsb https://github.com/KoalaFacts/HeroMessaging \
-m ./release-packages/sbom-abstractions
echo "✅ SBOM generated successfully"
ls -lh ./release-packages/sbom*/
ls -lh ./release-packages/sbom*/**/*.spdx.json || echo "SPDX files location may vary"
- name: List packages
run: |
echo "📦 Packages created:"
ls -lh ./release-packages/
echo ""
echo "📋 SBOM files:"
find ./release-packages -name "*.spdx.json" -o -name "_manifest" | head -10
echo ""
echo "Commit SHA: ${GITHUB_SHA}"
echo "Short SHA: ${GITHUB_SHA:0:7}"
echo "Build Number: ${{ steps.build-number.outputs.build-number }}"
echo "Full Version: ${{ steps.build-number.outputs.full-version }}"
- name: Upload release packages
uses: actions/upload-artifact@v5
with:
name: release-packages-${{ github.sha }}
path: ./release-packages/*
retention-days: 90 # Keep for 90 days for releases
- name: Upload release packages (latest)
uses: actions/upload-artifact@v5
with:
name: release-packages-latest
path: ./release-packages/*
retention-days: 90
- name: Upload SBOM artifacts
uses: actions/upload-artifact@v5
with:
name: sbom-${{ github.sha }}
path: ./release-packages/sbom*/**/*
retention-days: 90