# .gitea/workflows/schema-evolution.yml # Schema evolution testing workflow for backward/forward compatibility # Sprint: SPRINT_20260105_002_005_TEST_cross_cutting # Task: CCUT-012 # # WORKFLOW PURPOSE: # ================= # Validates that code changes remain compatible with previous database schema # versions (N-1, N-2). This prevents breaking changes when new code is deployed # before database migrations complete, or when rollbacks occur. # # Uses Testcontainers with versioned PostgreSQL images to replay tests against # historical schema versions. name: Schema Evolution Tests on: push: branches: [main] paths: - 'docs/db/**/*.sql' - 'src/**/Migrations/**' - 'src/**/*Repository*.cs' - 'src/**/*DbContext*.cs' - '.gitea/workflows/schema-evolution.yml' pull_request: paths: - 'docs/db/**/*.sql' - 'src/**/Migrations/**' - 'src/**/*Repository*.cs' - 'src/**/*DbContext*.cs' workflow_dispatch: inputs: schema_versions: description: 'Schema versions to test (comma-separated, e.g., N-1,N-2,N-3)' type: string default: 'N-1,N-2' modules: description: 'Modules to test (comma-separated, or "all")' type: string default: 'all' env: DOTNET_VERSION: '10.0.100' DOTNET_NOLOGO: 1 DOTNET_CLI_TELEMETRY_OPTOUT: 1 SCHEMA_VERSIONS: 'N-1,N-2' jobs: # =========================================================================== # DISCOVER SCHEMA-AFFECTED MODULES # =========================================================================== discover: name: Discover Changed Modules runs-on: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }} outputs: modules: ${{ steps.detect.outputs.modules }} has-schema-changes: ${{ steps.detect.outputs.has_changes }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Detect Schema Changes id: detect run: | # Get changed files if [ "${{ github.event_name }}" = "pull_request" ]; then CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }}) else CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD) fi echo "Changed files:" echo "$CHANGED_FILES" # Map files to modules MODULES="" if echo "$CHANGED_FILES" | grep -qE "src/Scanner/.*Repository|src/Scanner/.*Migrations|docs/db/.*scanner"; then MODULES="$MODULES,Scanner" fi if echo "$CHANGED_FILES" | grep -qE "src/Concelier/.*Repository|src/Concelier/.*Migrations|docs/db/.*concelier|docs/db/.*advisory"; then MODULES="$MODULES,Concelier" fi if echo "$CHANGED_FILES" | grep -qE "src/EvidenceLocker/.*Repository|src/EvidenceLocker/.*Migrations|docs/db/.*evidence"; then MODULES="$MODULES,EvidenceLocker" fi if echo "$CHANGED_FILES" | grep -qE "src/Authority/.*Repository|src/Authority/.*Migrations|docs/db/.*authority|docs/db/.*auth"; then MODULES="$MODULES,Authority" fi if echo "$CHANGED_FILES" | grep -qE "src/Policy/.*Repository|src/Policy/.*Migrations|docs/db/.*policy"; then MODULES="$MODULES,Policy" fi if echo "$CHANGED_FILES" | grep -qE "src/SbomService/.*Repository|src/SbomService/.*Migrations|docs/db/.*sbom"; then MODULES="$MODULES,SbomService" fi # Remove leading comma MODULES=$(echo "$MODULES" | sed 's/^,//') if [ -z "$MODULES" ]; then echo "has_changes=false" >> $GITHUB_OUTPUT echo "modules=[]" >> $GITHUB_OUTPUT echo "No schema-related changes detected" else echo "has_changes=true" >> $GITHUB_OUTPUT # Convert to JSON array MODULES_JSON=$(echo "$MODULES" | tr ',' '\n' | jq -R . | jq -s .) echo "modules=$MODULES_JSON" >> $GITHUB_OUTPUT echo "Detected modules: $MODULES" fi # =========================================================================== # RUN SCHEMA EVOLUTION TESTS # =========================================================================== test: name: Test ${{ matrix.module }} (Schema ${{ matrix.schema-version }}) needs: discover if: needs.discover.outputs.has-schema-changes == 'true' || github.event_name == 'workflow_dispatch' runs-on: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }} strategy: fail-fast: false matrix: module: ${{ fromJson(needs.discover.outputs.modules || '["Scanner","Concelier","EvidenceLocker"]') }} schema-version: ['N-1', 'N-2'] services: postgres: image: postgres:16-alpine env: POSTGRES_USER: stellaops_test POSTGRES_PASSWORD: test_password POSTGRES_DB: stellaops_schema_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: STELLAOPS_TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=stellaops_schema_test;Username=stellaops_test;Password=test_password" steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - name: Restore Dependencies run: dotnet restore src/StellaOps.sln - name: Get Schema Version id: schema run: | # Get current schema version from migration history CURRENT_VERSION=$(ls -1 docs/db/migrations/${{ matrix.module }}/*.sql 2>/dev/null | wc -l || echo "1") case "${{ matrix.schema-version }}" in "N-1") TARGET_VERSION=$((CURRENT_VERSION - 1)) ;; "N-2") TARGET_VERSION=$((CURRENT_VERSION - 2)) ;; "N-3") TARGET_VERSION=$((CURRENT_VERSION - 3)) ;; *) TARGET_VERSION=$CURRENT_VERSION ;; esac if [ "$TARGET_VERSION" -lt 1 ]; then echo "skip=true" >> $GITHUB_OUTPUT echo "No previous schema version available for ${{ matrix.schema-version }}" else echo "skip=false" >> $GITHUB_OUTPUT echo "target_version=$TARGET_VERSION" >> $GITHUB_OUTPUT echo "Testing against schema version: $TARGET_VERSION" fi - name: Apply Historical Schema if: steps.schema.outputs.skip != 'true' run: | # Apply schema up to target version TARGET=${{ steps.schema.outputs.target_version }} MODULE_LOWER=$(echo "${{ matrix.module }}" | tr '[:upper:]' '[:lower:]') echo "Applying schema migrations up to version $TARGET for $MODULE_LOWER" # Apply base schema if [ -f "docs/db/schemas/${MODULE_LOWER}.sql" ]; then psql "$STELLAOPS_TEST_POSTGRES_CONNECTION" -f "docs/db/schemas/${MODULE_LOWER}.sql" || true fi # Apply migrations up to target version MIGRATION_COUNT=0 for migration in $(ls -1 docs/db/migrations/${MODULE_LOWER}/*.sql 2>/dev/null | sort -V); do MIGRATION_COUNT=$((MIGRATION_COUNT + 1)) if [ "$MIGRATION_COUNT" -le "$TARGET" ]; then echo "Applying: $migration" psql "$STELLAOPS_TEST_POSTGRES_CONNECTION" -f "$migration" || true fi done echo "Applied $MIGRATION_COUNT migrations" - name: Run Schema Evolution Tests if: steps.schema.outputs.skip != 'true' id: test run: | # Find and run schema evolution tests for the module TEST_PROJECT="src/${{ matrix.module }}/__Tests/StellaOps.${{ matrix.module }}.SchemaEvolution.Tests" if [ -d "$TEST_PROJECT" ]; then dotnet test "$TEST_PROJECT" \ --configuration Release \ --no-restore \ --verbosity normal \ --logger "trx;LogFileName=schema-evolution-${{ matrix.module }}-${{ matrix.schema-version }}.trx" \ --results-directory ./test-results \ -- RunConfiguration.EnvironmentVariables.SCHEMA_VERSION="${{ matrix.schema-version }}" else # Run tests with SchemaEvolution category from main test project TEST_PROJECT="src/${{ matrix.module }}/__Tests/StellaOps.${{ matrix.module }}.Tests" if [ -d "$TEST_PROJECT" ]; then dotnet test "$TEST_PROJECT" \ --configuration Release \ --no-restore \ --verbosity normal \ --filter "Category=SchemaEvolution" \ --logger "trx;LogFileName=schema-evolution-${{ matrix.module }}-${{ matrix.schema-version }}.trx" \ --results-directory ./test-results \ -- RunConfiguration.EnvironmentVariables.SCHEMA_VERSION="${{ matrix.schema-version }}" else echo "No test project found for ${{ matrix.module }}" echo "skip_reason=no_tests" >> $GITHUB_OUTPUT fi fi - name: Upload Test Results if: always() && steps.schema.outputs.skip != 'true' uses: actions/upload-artifact@v4 with: name: schema-evolution-results-${{ matrix.module }}-${{ matrix.schema-version }} path: ./test-results/*.trx if-no-files-found: ignore # =========================================================================== # COMPATIBILITY MATRIX REPORT # =========================================================================== report: name: Generate Compatibility Report needs: [discover, test] if: always() && needs.discover.outputs.has-schema-changes == 'true' runs-on: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }} steps: - name: Download All Results uses: actions/download-artifact@v4 with: pattern: schema-evolution-results-* merge-multiple: true path: ./results continue-on-error: true - name: Generate Report run: | cat > schema-compatibility-report.md << 'EOF' ## Schema Evolution Compatibility Report | Module | Schema N-1 | Schema N-2 | |--------|------------|------------| EOF # Parse test results and generate matrix for module in Scanner Concelier EvidenceLocker Authority Policy SbomService; do N1_STATUS="-" N2_STATUS="-" if [ -f "results/schema-evolution-${module}-N-1.trx" ]; then if grep -q 'outcome="Passed"' "results/schema-evolution-${module}-N-1.trx" 2>/dev/null; then N1_STATUS=":white_check_mark:" elif grep -q 'outcome="Failed"' "results/schema-evolution-${module}-N-1.trx" 2>/dev/null; then N1_STATUS=":x:" fi fi if [ -f "results/schema-evolution-${module}-N-2.trx" ]; then if grep -q 'outcome="Passed"' "results/schema-evolution-${module}-N-2.trx" 2>/dev/null; then N2_STATUS=":white_check_mark:" elif grep -q 'outcome="Failed"' "results/schema-evolution-${module}-N-2.trx" 2>/dev/null; then N2_STATUS=":x:" fi fi echo "| $module | $N1_STATUS | $N2_STATUS |" >> schema-compatibility-report.md done echo "" >> schema-compatibility-report.md echo "*Report generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)*" >> schema-compatibility-report.md cat schema-compatibility-report.md - name: Upload Report uses: actions/upload-artifact@v4 with: name: schema-compatibility-report path: schema-compatibility-report.md # =========================================================================== # POST REPORT TO PR # =========================================================================== comment: name: Post Report to PR needs: [discover, test, report] if: github.event_name == 'pull_request' && always() runs-on: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }} permissions: pull-requests: write steps: - name: Download Report uses: actions/download-artifact@v4 with: name: schema-compatibility-report continue-on-error: true - name: Post Comment uses: actions/github-script@v7 with: script: | const fs = require('fs'); let report = ''; try { report = fs.readFileSync('schema-compatibility-report.md', 'utf8'); } catch (e) { report = 'Schema compatibility report not available.'; } const hasChanges = '${{ needs.discover.outputs.has-schema-changes }}' === 'true'; if (!hasChanges) { return; // No schema changes, no comment needed } const body = `## Schema Evolution Test Results This PR includes changes that may affect database compatibility. ${report} ---
About Schema Evolution Tests Schema evolution tests verify that: - Current code works with previous schema versions (N-1, N-2) - Rolling deployments don't break during migration windows - Rollbacks are safe when schema hasn't been migrated yet If tests fail, consider: 1. Adding backward-compatible default values 2. Using nullable columns for new fields 3. Creating migration-safe queries 4. Updating the compatibility matrix
`; // Find existing comment const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); const botComment = comments.find(c => c.user.type === 'Bot' && c.body.includes('Schema Evolution Test Results') ); if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: body }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body }); }