Files
git.stella-ops.org/.gitea/workflows/schema-evolution.yml
2026-01-08 08:54:27 +02:00

420 lines
15 KiB
YAML

# .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}
---
<details>
<summary>About Schema Evolution Tests</summary>
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
</details>
`;
// 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
});
}