420 lines
15 KiB
YAML
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
|
|
});
|
|
}
|
|
|