name: Regression tests on: workflow_dispatch: pull_request: branches: - main - 'release/**' push: branches: - main - 'release/**' permissions: contents: read # Supersede in-flight runs of this workflow for the same pull request. Pushes to main and # release branches are excluded so every commit there still gets a full, recorded result. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: regression-tests: name: End-to-end regression tests runs-on: ubuntu-24.04 timeout-minutes: 180 env: DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true DevExpress_License: ${{ secrets.DEVEXPRESS_LICENSE }} ACR_USERNAME: ${{ secrets.ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} DB_USERNAME: sa DB_PASSWORD: SA!password DB_PORT: 1433 steps: - name: Check out repository uses: actions/checkout@v7 with: fetch-depth: 0 # Verifier.OverrideExpectedFiles = true rewrites the expected files instead of comparing # against them, so a test that keeps the flag silently verifies nothing. It is only meant # to be set temporarily while regenerating snapshots and must never be committed. - name: Check for committed OverrideExpectedFiles shell: pwsh run: | $violations = foreach ($file in Get-ChildItem -Path ./tests -Recurse -Filter *.cs) { $segments = $file.FullName -split '[\\/]' if ($segments -contains 'bin' -or $segments -contains 'obj') { continue } $text = Get-Content -LiteralPath $file.FullName -Raw if ([string]::IsNullOrEmpty($text)) { continue } # Blank out block comments, keeping newlines so reported line numbers stay correct. $text = [regex]::Replace($text, '(?s)/\*.*?\*/', { param($m) $m.Value -replace '[^\r\n]', '' }) $lineNumber = 0 foreach ($line in $text -split '\r?\n') { $lineNumber++ # The assignment must start the line, optionally behind a dotted receiver such as # this.Verifier. - anything else in front (other code, // or /// ) means it is not a # statement. Deliberately not stripping // comments: that would also cut a real # assignment that follows a string containing '//', turning a false positive into a # false negative. if ($line -match '^\s*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?OverrideExpectedFiles\s*=\s*true') { [pscustomobject]@{ Path = $file.FullName; Line = $lineNumber } } } } if ($violations) { foreach ($violation in $violations) { Write-Host "::error file=$($violation.Path),line=$($violation.Line)::Remove 'OverrideExpectedFiles = true' before committing - the test compares nothing while it is set." } throw "Found $(@($violations).Count) committed 'OverrideExpectedFiles = true' assignment(s). Regenerate the expected files locally, then remove the flag." } Write-Host 'No committed OverrideExpectedFiles assignments found.' - name: Set up .NET SDK uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - name: Verify test environment shell: pwsh run: | $sdkVersion = dotnet --version if ($LASTEXITCODE -ne 0 -or $sdkVersion -notmatch '^10\.0\.') { throw "Expected a .NET 10 SDK, but resolved '$sdkVersion'." } docker version if ($LASTEXITCODE -ne 0) { throw 'Docker is unavailable on the self-hosted runner.' } - name: Sign in to Azure Container Registry shell: pwsh run: | if ([string]::IsNullOrWhiteSpace($env:ACR_USERNAME) -or [string]::IsNullOrWhiteSpace($env:ACR_PASSWORD)) { throw 'ACR_USERNAME and ACR_PASSWORD secrets are required on GitHub-hosted runners.' } $env:ACR_PASSWORD | docker login centron.azurecr.io --username $env:ACR_USERNAME --password-stdin if ($LASTEXITCODE -ne 0) { throw 'Azure Container Registry login failed.' } # Superseded runs are now cancelled mid-test. "Stop regression database" below uses # if: always() and therefore still runs on cancellation, but a hard runner failure can # leave a container behind. Drop anything older than the job timeout so leftovers cannot # pile up on the self-hosted runner. Younger containers may belong to a concurrent run of # another pull request and are left alone. - name: Remove stale regression containers continue-on-error: true shell: pwsh run: | $cutoff = (Get-Date).ToUniversalTime().AddHours(-4) # The docker name filter is a regex over a substring, so an unanchored pattern would # also match something like backup-centron-regression-db. '^/?' anchors it and works # whether the daemon matches the bare name or the internal '/name'. foreach ($id in @(docker ps --all --quiet --filter 'name=^/?centron-regression-')) { if ([string]::IsNullOrWhiteSpace($id)) { continue } $parts = (docker inspect --format '{{.Name}}|{{.Created}}' $id) -split '\|', 2 if ($parts.Count -ne 2) { continue } $name = $parts[0].TrimStart('/') $created = $parts[1] # Second guard: this force-removes containers, so never act on a name that does not # actually carry the prefix, whatever the daemon's filter semantics happen to be. if (-not $name.StartsWith('centron-regression-')) { continue } $parsed = [datetime]::MinValue $isParsed = [datetime]::TryParse( $created, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref] $parsed) if ($isParsed -and $parsed -lt $cutoff) { Write-Host "Removing stale regression container $name created at $created." docker rm --force $id | Out-Null } } $global:LASTEXITCODE = 0 - name: Start regression database shell: pwsh run: | $containerName = "centron-regression-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT".ToLowerInvariant() "DB_CONTAINER_NAME=$containerName" >> $env:GITHUB_ENV docker pull centron.azurecr.io/centron_db/regression_tests:latest if ($LASTEXITCODE -ne 0) { throw 'Could not pull the regression database image.' } docker run --detach ` --name $containerName ` --env "MSSQL_SA_PASSWORD=$env:DB_PASSWORD" ` --env ACCEPT_EULA=Y ` --env MSSQL_PID=Standard ` --publish "${env:DB_PORT}:1433" ` centron.azurecr.io/centron_db/regression_tests:latest | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Could not start the regression database container.' } - name: Wait for regression database shell: pwsh run: | for ($attempt = 1; $attempt -le 120; $attempt++) { $client = [Net.Sockets.TcpClient]::new() try { $connected = $client.ConnectAsync('localhost', [int]$env:DB_PORT).Wait(1000) if ($connected -and $client.Connected) { Write-Host 'Regression database is reachable.' exit 0 } } catch { # Database is still starting. } finally { $client.Dispose() } Start-Sleep -Seconds 2 } throw 'Regression database did not become reachable within four minutes.' - name: Build regression tests shell: pwsh run: | dotnet build ` "./tests/Centron.Tests.EndToEnd/Centron.Tests.EndToEnd.csproj" ` --configuration Release ` --framework net10.0 ` -nodeReuse:false - name: Run regression tests shell: pwsh env: CENTRON_TESTS_DATABASE_SERVER: localhost,1433 CENTRON_TESTS_DATABASE_USERNAME: sa CENTRON_TESTS_DATABASE_PASSWORD: SA!password DATABASE_BACKUP_PATH: /var/opt/mssql/backup/DatabaseBackup.bak run: | dotnet test ` "./tests/Centron.Tests.EndToEnd/Centron.Tests.EndToEnd.csproj" ` --configuration Release ` --framework net10.0 ` --no-build ` --logger "trx;LogFileName=TestResults.trx" ` --results-directory "./artifacts/EndToEndTests" ` -nodeReuse:false - name: Capture database logs if: always() continue-on-error: true shell: pwsh run: | New-Item -ItemType Directory -Force -Path './artifacts/EndToEndTests' | Out-Null docker logs $env:DB_CONTAINER_NAME *>&1 | Set-Content -LiteralPath './artifacts/EndToEndTests/database.log' - name: Stop regression database if: always() continue-on-error: true shell: pwsh run: | if (-not [string]::IsNullOrWhiteSpace($env:DB_CONTAINER_NAME)) { docker rm --force $env:DB_CONTAINER_NAME 2>$null } $global:LASTEXITCODE = 0 - name: Upload regression results if: always() uses: actions/upload-artifact@v7 with: name: regression-test-results path: artifacts/EndToEndTests/ if-no-files-found: warn retention-days: 14