<# .SYNOPSIS Runs the repository quality gate with bounded execution time and concise output. .DESCRIPTION Modes and approximate local duration, measured on June 6, 2026: Mode Command Runs Expected Quick ./eng/check.ps1 -Quick restore, format, architecture build/tests 3-7 min Default ./eng/check.ps1 Quick + solution build, fast tests, secrets 4-10 min Full ./eng/check.ps1 -Full Default + all tests and package audits 20-25 min Typical step durations: NuGet preflight < 1 sec Restore 10-40 sec Changed-file format 30-100 sec Architecture build 2-5 min Solution build 1-7 min Architecture tests < 10 sec Application unit tests 10-30 sec Blazor client tests 1-2 min Application integration 7-9 min Domain integration < 30 sec EF Core integration 2-3 min Secret hygiene 30-60 sec Package audits 1-2 min Times vary with NuGet caches, incremental build state, machine load, and whether integration-test infrastructure is already warm. CI can take longer. Each external command has its own timeout, while the complete gate also has a mode-specific total timeout. Use -Diagnostic to retain complete command output under artifacts/check when troubleshooting. .PARAMETER Quick Selects the fast inner-loop gate. .PARAMETER Full Selects the extended merge/release gate. .PARAMETER Configuration Chooses the Debug or Release build configuration. Defaults to Debug. .PARAMETER NoRestore Skips NuGet feed validation and package restore. Use only after a successful restore when package and restore inputs have not changed. .PARAMETER CompareBaseRef Includes committed changes between this Git ref and HEAD in scoped formatting. CI supplies the previous push SHA or pull-request merge parent. .PARAMETER SkipIntegrationTests In Full mode, skips Application, Domain, and EF Core integration projects when they run in separate CI matrix jobs. .PARAMETER StepTimeoutMinutes Overrides every per-step timeout. Zero uses the step-specific defaults. .PARAMETER MaxTotalMinutes Overrides the total gate timeout. Zero uses the mode-specific default. .PARAMETER Diagnostic Writes complete output for each external command to artifacts/check. .EXAMPLE ./eng/check.ps1 -Quick Runs the fail-fast inner-loop gate. .EXAMPLE ./eng/check.ps1 Runs the default completion gate. .EXAMPLE ./eng/check.ps1 -Full -Configuration Release -Diagnostic Runs the extended release gate and preserves complete diagnostic logs. #> [CmdletBinding(DefaultParameterSetName = "Default")] param( [Parameter(ParameterSetName = "Quick")] [switch]$Quick, [Parameter(ParameterSetName = "Full")] [switch]$Full, [Parameter()] [ValidateSet("Debug", "Release")] [string]$Configuration = "Debug", [Parameter()] [switch]$NoRestore, [Parameter()] [string]$CompareBaseRef = "", [Parameter(ParameterSetName = "Full")] [switch]$SkipIntegrationTests, [Parameter()] [ValidateRange(0, 240)] [int]$StepTimeoutMinutes = 0, [Parameter()] [ValidateRange(0, 480)] [int]$MaxTotalMinutes = 0, [Parameter()] [switch]$Diagnostic ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # Resolve all paths from the script location so invocation works from any directory. $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $solutionPath = Join-Path $repoRoot "ST.YourSite.sln" $diagnosticRoot = Join-Path $repoRoot "artifacts/check" $testResultsRoot = Join-Path $repoRoot "test-results/check" # Test projects are grouped by the earliest gate mode that should execute them. $architectureTestProject = "test/ST.YourSite.Architecture.Tests/ST.YourSite.Architecture.Tests.csproj" $fastTestProjects = @( "test/ST.YourSite.Application.UnitTests/ST.YourSite.Application.UnitTests.csproj", "test/ST.YourSite.Blazor.Client.Tests/ST.YourSite.Blazor.Client.Tests.csproj" ) $fullTestProjects = @( "test/ST.YourSite.Blazor.Tests/ST.YourSite.Blazor.Tests.csproj" ) $integrationTestProjects = @( "test/ST.YourSite.Application.Tests/ST.YourSite.Application.Tests.csproj", "test/ST.YourSite.Domain.Tests/ST.YourSite.Domain.Tests.csproj", "test/ST.YourSite.EntityFrameworkCore.Tests/ST.YourSite.EntityFrameworkCore.Tests.csproj" ) # Individual commands receive limits appropriate to their expected workload. $defaultTimeoutMinutesByKind = @{ Git = 2 Restore = 10 Format = 5 BuildSolution = 15 BuildArchitecture = 10 ArchitectureTests = 5 FastTests = 8 FullTests = 12 SecretHygiene = 5 PackageAudit = 5 } # The total budget prevents a series of individually valid steps from running indefinitely. $defaultTotalTimeoutMinutesByMode = @{ Quick = 10 Default = 15 Full = 25 } # Translate the mutually exclusive switches into the mode name used by logs and budgets. function Get-GateModeName { if ($Quick) { return "Quick" } if ($Full) { return "Full" } return "Default" } # Use an explicit total-time override when supplied; otherwise use the selected mode default. function Get-TotalTimeoutMinutes { if ($MaxTotalMinutes -gt 0) { return $MaxTotalMinutes } $mode = Get-GateModeName if (-not $defaultTotalTimeoutMinutesByMode.ContainsKey($mode)) { throw "Unknown gate mode '$mode'." } return $defaultTotalTimeoutMinutesByMode[$mode] } $gateModeName = Get-GateModeName $gateTotalTimeoutMinutes = Get-TotalTimeoutMinutes $gateDeadline = [DateTimeOffset]::Now.AddMinutes($gateTotalTimeoutMinutes) # Report whether the shared wall-clock budget for the complete gate has expired. function Test-GateBudgetExceeded { return [DateTimeOffset]::Now -gt $gateDeadline } # Fail before starting another step when no total gate budget remains. function Assert-GateBudget { param( [Parameter(Mandatory)] [string]$StepName ) if (Test-GateBudgetExceeded) { throw "Quality gate exceeded the $gateModeName total timeout of $gateTotalTimeoutMinutes minute(s) before starting '$StepName'." } } # Resolve a command timeout from the global override or its workload category. function Get-StepTimeoutMinutes { param( [Parameter(Mandatory)] [string]$Kind ) if ($StepTimeoutMinutes -gt 0) { return $StepTimeoutMinutes } if (-not $defaultTimeoutMinutesByKind.ContainsKey($Kind)) { throw "Unknown gate timeout kind '$Kind'." } return $defaultTimeoutMinutesByKind[$Kind] } # Render durations compactly for progress and completion messages. function Format-Elapsed { param( [Parameter(Mandatory)] [TimeSpan]$Elapsed ) if ($Elapsed.TotalHours -ge 1) { return "{0:hh\:mm\:ss}" -f $Elapsed } return "{0:mm\:ss}" -f $Elapsed } # Quote command arguments only when whitespace or embedded quotes require it. function Format-CommandArgument { param( [Parameter(Mandatory)] [string]$Argument ) if ($Argument -match '[\s"]') { return '"' + $Argument.Replace('"', '\"') + '"' } return $Argument } # Build a human-readable command line for logging; Process.ArgumentList is used for execution. function Format-CommandLine { param( [Parameter(Mandatory)] [string]$FilePath, [Parameter(Mandatory)] [string[]]$Arguments ) $formattedArguments = foreach ($argument in $Arguments) { Format-CommandArgument -Argument $argument } return "$FilePath $($formattedArguments -join ' ')" } # Keep very long generated commands readable while retaining size and argument diagnostics. function Format-CommandLineForLog { param( [Parameter(Mandatory)] [string]$CommandLine, [Parameter(Mandatory)] [int]$ArgumentCount, [Parameter()] [int]$MaxLength = 600 ) if ($CommandLine.Length -le $MaxLength) { return $CommandLine } return "$($CommandLine.Substring(0, $MaxLength)) ... [truncated; $($CommandLine.Length) chars, $ArgumentCount args]" } # Convert a step label into a file-system-safe diagnostic log name. function Format-SafeName { param( [Parameter(Mandatory)] [string]$Name ) return [regex]::Replace($Name, "[^A-Za-z0-9_.-]+", "-").Trim("-") } # Prefix gate output with a timestamp so stalls and slow steps are visible. function Write-GateMessage { param( [Parameter(Mandatory)] [string]$Message ) Write-Host "[$([DateTimeOffset]::Now.ToString("HH:mm:ss"))] $Message" } # Group each step in GitHub Actions logs without changing local console output. function Start-GateGroup { param( [Parameter(Mandatory)] [string]$Name ) if ($env:GITHUB_ACTIONS -eq "true") { Write-Host "::group::$Name" } } # Close a GitHub Actions log group when running in CI. function Stop-GateGroup { if ($env:GITHUB_ACTIONS -eq "true") { Write-Host "::endgroup::" } } # Limit failure output to the most relevant trailing lines. function Get-CommandTail { param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Lines, [Parameter()] [int]$Count = 60 ) if ($Lines.Count -le $Count) { return $Lines } return $Lines[($Lines.Count - $Count)..($Lines.Count - 1)] } # Persist full command output only when diagnostic mode is enabled. function Write-DiagnosticLog { param( [Parameter(Mandatory)] [string]$StepName, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Lines ) if (-not $Diagnostic) { return } if (-not (Test-Path -LiteralPath $diagnosticRoot)) { New-Item -ItemType Directory -Path $diagnosticRoot -Force | Out-Null } $safeStepName = Format-SafeName -Name $StepName $timestamp = [DateTimeOffset]::Now.ToString("yyyyMMdd-HHmmss") $logPath = Join-Path $diagnosticRoot "$timestamp-$safeStepName.log" $Lines | Set-Content -LiteralPath $logPath -Encoding utf8 Write-GateMessage "Diagnostic log: $logPath" } # Normalize captured process output into non-empty lines. function Split-CommandOutput { param( [AllowNull()] [string]$Output ) if ([string]::IsNullOrEmpty($Output)) { return @() } return @($Output -split "\r?\n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } # Merge asynchronously captured stdout and stderr after a process exits. function Get-ProcessOutputLines { param( [Parameter(Mandatory)] [System.Threading.Tasks.Task[string]]$StandardOutputTask, [Parameter(Mandatory)] [System.Threading.Tasks.Task[string]]$StandardErrorTask ) $standardOutput = $StandardOutputTask.GetAwaiter().GetResult() $standardError = $StandardErrorTask.GetAwaiter().GetResult() $lines = [System.Collections.Generic.List[string]]::new() foreach ($line in @(Split-CommandOutput -Output $standardOutput)) { $lines.Add($line) | Out-Null } foreach ($line in @(Split-CommandOutput -Output $standardError)) { $lines.Add($line) | Out-Null } return $lines.ToArray() } # Terminate a timed-out command and its descendants, with compatibility fallbacks. function Stop-ProcessTree { param( [Parameter(Mandatory)] [System.Diagnostics.Process]$Process ) try { if ($Process.HasExited) { return } } catch [System.InvalidOperationException] { return } try { $Process.Kill($true) } catch [System.InvalidOperationException] { return } catch { try { $Process.Kill() } catch [System.InvalidOperationException] { return } } try { $Process.WaitForExit(5000) | Out-Null } catch [System.InvalidOperationException] { return } } # Execute a command without a shell, enforce both timeout layers, and control output volume. function Invoke-ExternalCommand { param( [Parameter(Mandatory)] [string]$StepName, [Parameter(Mandatory)] [string]$FilePath, [Parameter(Mandatory)] [string[]]$Arguments, [Parameter(Mandatory)] [int]$TimeoutMinutes, [Parameter()] [switch]$Quiet, [Parameter()] [switch]$ReturnOutput ) $commandLine = Format-CommandLine -FilePath $FilePath -Arguments $Arguments $commandLineForLog = Format-CommandLineForLog -CommandLine $commandLine -ArgumentCount $Arguments.Count if (-not $Quiet) { Write-GateMessage "Command: $commandLineForLog" Write-GateMessage "Timeout: $TimeoutMinutes minute(s)" } # ArgumentList avoids shell parsing and preserves each argument exactly. $process = [System.Diagnostics.Process]::new() $process.StartInfo.FileName = $FilePath foreach ($argument in $Arguments) { $process.StartInfo.ArgumentList.Add($argument) } $process.StartInfo.WorkingDirectory = $repoRoot $process.StartInfo.UseShellExecute = $false $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true $process.StartInfo.CreateNoWindow = $true $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $timeout = [TimeSpan]::FromMinutes($TimeoutMinutes) $lastHeartbeat = [TimeSpan]::Zero $standardOutputTask = $null $standardErrorTask = $null try { if (-not $process.Start()) { throw "Failed to start command: $commandLineForLog" } $standardOutputTask = $process.StandardOutput.ReadToEndAsync() $standardErrorTask = $process.StandardError.ReadToEndAsync() # Poll once per second so timeouts and progress heartbeats remain responsive. while (-not $process.HasExited) { if ($process.WaitForExit(1000)) { break } # A step timeout identifies a single command that exceeded its expected duration. if ($stopwatch.Elapsed -gt $timeout) { Stop-ProcessTree -Process $process $outputLines = @(Get-ProcessOutputLines -StandardOutputTask $standardOutputTask -StandardErrorTask $standardErrorTask) $tail = @(Get-CommandTail -Lines $outputLines) Write-DiagnosticLog -StepName $StepName -Lines $outputLines if ($tail.Count -gt 0) { Write-Host "" Write-Host "Last output before timeout:" $tail | ForEach-Object { Write-Host $_ } } throw "$StepName timed out after $TimeoutMinutes minute(s): $commandLineForLog" } # The total timeout bounds the complete gate even when each command is within its limit. if (Test-GateBudgetExceeded) { Stop-ProcessTree -Process $process $outputLines = @(Get-ProcessOutputLines -StandardOutputTask $standardOutputTask -StandardErrorTask $standardErrorTask) $tail = @(Get-CommandTail -Lines $outputLines) Write-DiagnosticLog -StepName $StepName -Lines $outputLines if ($tail.Count -gt 0) { Write-Host "" Write-Host "Last output before total gate timeout:" $tail | ForEach-Object { Write-Host $_ } } throw "Quality gate exceeded the $gateModeName total timeout of $gateTotalTimeoutMinutes minute(s) while running '$StepName': $commandLineForLog" } # Heartbeats distinguish a slow command from a detached or silent process. if (($stopwatch.Elapsed - $lastHeartbeat).TotalSeconds -ge 30) { $lastHeartbeat = $stopwatch.Elapsed if (-not $Quiet) { Write-GateMessage "Still running '$StepName' after $(Format-Elapsed -Elapsed $stopwatch.Elapsed)." } } } $process.WaitForExit() $stopwatch.Stop() $outputLines = @(Get-ProcessOutputLines -StandardOutputTask $standardOutputTask -StandardErrorTask $standardErrorTask) Write-DiagnosticLog -StepName $StepName -Lines $outputLines if ($process.ExitCode -ne 0) { $tail = @(Get-CommandTail -Lines $outputLines) if ($tail.Count -gt 0) { Write-Host "" Write-Host "Last output before failure:" $tail | ForEach-Object { Write-Host $_ } } throw "$StepName failed with exit code $($process.ExitCode): $commandLineForLog" } # Successful commands remain concise unless diagnostic output was requested. if (-not $Quiet -and $outputLines.Count -gt 0) { $successOutputLimit = 120 if ($Diagnostic -or $outputLines.Count -le $successOutputLimit) { $outputLines | ForEach-Object { Write-Host $_ } } else { Write-GateMessage "Command produced $($outputLines.Count) output line(s); showing last $successOutputLimit. Use -Diagnostic for full logs." Get-CommandTail -Lines $outputLines -Count $successOutputLimit | ForEach-Object { Write-Host $_ } } } if ($ReturnOutput) { return $outputLines } } finally { try { if (-not $process.HasExited) { Stop-ProcessTree -Process $process } } catch [System.InvalidOperationException] { } $process.Dispose() } } # Wrap a logical gate step with timing, CI grouping, and consistent success/failure reporting. function Invoke-GateStep { param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [scriptblock]$Script ) Write-Host "" Start-GateGroup -Name $Name Assert-GateBudget -StepName $Name Write-GateMessage "==> $Name" $stepStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { & $Script $stepStopwatch.Stop() Write-GateMessage "OK: $Name ($(Format-Elapsed -Elapsed $stepStopwatch.Elapsed))" } catch { $stepStopwatch.Stop() Write-GateMessage "FAILED: $Name ($(Format-Elapsed -Elapsed $stepStopwatch.Elapsed))" throw } finally { Stop-GateGroup } } # Apply the configured timeout category to a dotnet invocation. function Invoke-DotNet { param( [Parameter(Mandatory)] [string]$StepName, [Parameter(Mandatory)] [string[]]$Arguments, [Parameter(Mandatory)] [string]$TimeoutKind ) Invoke-ExternalCommand ` -StepName $StepName ` -FilePath "dotnet" ` -Arguments $Arguments ` -TimeoutMinutes (Get-StepTimeoutMinutes -Kind $TimeoutKind) } # Batch long --include lists to stay below Windows command-line length limits. function Split-LongArgumentList { param( [Parameter(Mandatory)] [string[]]$BaseArguments, [Parameter(Mandatory)] [string[]]$Items, [Parameter()] [int]$MaxCommandLineLength = 24000 ) $batches = [System.Collections.Generic.List[object]]::new() $current = [System.Collections.Generic.List[string]]::new() foreach ($item in $Items) { $candidateItems = @($current.ToArray()) + @($item) $candidateLength = (Format-CommandLine -FilePath "dotnet" -Arguments ($BaseArguments + $candidateItems)).Length if ($candidateLength -gt $MaxCommandLineLength -and $current.Count -gt 0) { $batches.Add([pscustomobject]@{ Items = $current.ToArray() }) | Out-Null $current = [System.Collections.Generic.List[string]]::new() $candidateItems = @($item) $candidateLength = (Format-CommandLine -FilePath "dotnet" -Arguments ($BaseArguments + $candidateItems)).Length } if ($candidateLength -gt $MaxCommandLineLength) { throw "A single argument exceeds the command-line budget of $MaxCommandLineLength characters: $item" } $current.Add($item) | Out-Null } if ($current.Count -gt 0) { $batches.Add([pscustomobject]@{ Items = $current.ToArray() }) | Out-Null } return $batches.ToArray() } # Run a package report and warn on known findings without blocking the current baseline. function Invoke-PackageAuditReport { param( [Parameter(Mandatory)] [string]$StepName, [Parameter(Mandatory)] [string[]]$Arguments, [Parameter(Mandatory)] [string]$FindingMarker ) $outputLines = @(Invoke-ExternalCommand ` -StepName $StepName ` -FilePath "dotnet" ` -Arguments $Arguments ` -TimeoutMinutes (Get-StepTimeoutMinutes -Kind "PackageAudit") ` -ReturnOutput) if ($outputLines | Where-Object { $_.Contains($FindingMarker, [StringComparison]::Ordinal) }) { Write-Warning "$StepName reported package findings. This is an intentional non-blocking round-2 baseline until package upgrades or approved suppressions are complete." } } # Run quiet git queries through the same bounded process runner. function Invoke-Git { param( [Parameter(Mandatory)] [string[]]$Arguments ) return Invoke-ExternalCommand ` -StepName "git $($Arguments -join ' ')" ` -FilePath "git" ` -Arguments $Arguments ` -TimeoutMinutes (Get-StepTimeoutMinutes -Kind "Git") ` -Quiet ` -ReturnOutput } # Fail before restore when the ABP commercial feed placeholder cannot be resolved. function Test-NuGetFeedConfiguration { $nugetConfigPath = Join-Path $repoRoot "NuGet.Config" if (-not (Test-Path -LiteralPath $nugetConfigPath)) { return } [xml]$nugetConfig = Get-Content -LiteralPath $nugetConfigPath -Raw $abpSource = $nugetConfig.configuration.packageSources.add | Where-Object { $_.key -eq "nuget.abp.io" } | Select-Object -First 1 if ($null -eq $abpSource) { return } $sourceUrl = [string]$abpSource.value if ($sourceUrl -match "/nuget/v3/index\.json$" -or $sourceUrl -match "YOUR-KEY|LICENSE-KEY|PLACEHOLDER|ABP_NUGET_API_KEY") { throw "NuGet.Config still contains the placeholder ABP feed URL. Configure nuget.abp.io before running restore." } if ($sourceUrl.Contains("%NUGET_ABP_KEY%", [StringComparison]::Ordinal) -and [string]::IsNullOrWhiteSpace($env:NUGET_ABP_KEY)) { $persistedKey = $null if ($IsWindows) { foreach ($target in [EnvironmentVariableTarget]::User, [EnvironmentVariableTarget]::Machine) { $persistedKey = [Environment]::GetEnvironmentVariable("NUGET_ABP_KEY", $target) if (-not [string]::IsNullOrWhiteSpace($persistedKey)) { $env:NUGET_ABP_KEY = $persistedKey Write-GateMessage "Loaded NUGET_ABP_KEY from the Windows $target environment." break } } } if ([string]::IsNullOrWhiteSpace($env:NUGET_ABP_KEY)) { throw "NuGet.Config uses %NUGET_ABP_KEY% for the ABP feed URL. Set it in the process, Windows user, or Windows machine environment, or run with -NoRestore after a successful restore." } } } # Return changed and untracked C# source files eligible for scoped formatting checks. function Get-ChangedCSharpFiles { $paths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $committedChanges = @() if (-not [string]::IsNullOrWhiteSpace($CompareBaseRef)) { $committedChanges = @( Invoke-Git -Arguments @( "diff", "--name-only", "--diff-filter=ACMRTUXB", "$CompareBaseRef...HEAD", "--" ) ) } $trackedChanges = @(Invoke-Git -Arguments @("diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD", "--")) $untrackedChanges = @(Invoke-Git -Arguments @("ls-files", "--others", "--exclude-standard")) foreach ($relativePath in $committedChanges + $trackedChanges + $untrackedChanges) { if ([string]::IsNullOrWhiteSpace($relativePath)) { continue } $normalizedPath = $relativePath.Replace("\", "/") if ($normalizedPath.StartsWith("LeptonX/", [StringComparison]::OrdinalIgnoreCase) ` -or $normalizedPath.Contains("/bin/", [StringComparison]::OrdinalIgnoreCase) ` -or $normalizedPath.Contains("/obj/", [StringComparison]::OrdinalIgnoreCase) ` -or -not $normalizedPath.EndsWith(".cs", [StringComparison]::OrdinalIgnoreCase)) { continue } $absolutePath = Join-Path $repoRoot $normalizedPath $extendedPath = $absolutePath -replace "\.cs$", ".Extended.cs" if (-not $normalizedPath.EndsWith(".Extended.cs", [StringComparison]::OrdinalIgnoreCase) ` -and (Test-Path -LiteralPath $extendedPath -PathType Leaf)) { continue } $paths.Add($normalizedPath) | Out-Null } return @($paths | Sort-Object) } # Derive a stable test result filename from a project path. function Get-TestProjectName { param( [Parameter(Mandatory)] [string]$ProjectPath ) return [System.IO.Path]::GetFileNameWithoutExtension($ProjectPath) } # Trigger dotnet-test hang collection before the outer command timeout kills the process. function Get-BlameHangTimeout { param( [Parameter(Mandatory)] [string]$TimeoutKind ) $timeoutMinutes = Get-StepTimeoutMinutes -Kind $TimeoutKind return "$([Math]::Max(1, $timeoutMinutes - 1))m" } # Build the common dotnet test arguments, including TRX output and hang diagnostics. function Get-TestArguments { param( [Parameter(Mandatory)] [string]$ProjectPath, [Parameter(Mandatory)] [string]$TimeoutKind ) if (-not (Test-Path -LiteralPath $testResultsRoot)) { New-Item -ItemType Directory -Path $testResultsRoot -Force | Out-Null } $projectName = Get-TestProjectName -ProjectPath $ProjectPath $trxName = "$projectName.trx" return @( "test", $ProjectPath, "--configuration", $Configuration, "--no-build", "--logger", "console;verbosity=minimal", "--logger", "trx;LogFileName=$trxName", "--results-directory", $testResultsRoot, "--blame-hang", "--blame-hang-timeout", (Get-BlameHangTimeout -TimeoutKind $TimeoutKind) ) } # Scan tracked, reasonably sized text/config files for common committed-secret signatures. function Test-TrackedSecretHygiene { param( [Parameter()] [DateTimeOffset]$Deadline = [DateTimeOffset]::MaxValue ) $secretPatterns = [ordered]@{ "private key material" = "-----BEGIN [A-Z ]*PRIVATE KEY-----" "GitHub token" = "gh[pousr]_[A-Za-z0-9_]{36,}" "Azure DevOps token" = "azdpat[a-z0-9]{52}" "ABP NuGet feed key in URL" = "https://nuget\.abp\.io/[A-Za-z0-9_-]{20,}/v3/index\.json" "storage account key" = "AccountKey=[A-Za-z0-9+/=]{60,}" } $credentialAssignmentPattern = "(?i)(client[_-]?secret|oauth[^:=\r\n]*secret|access[_-]?token|refresh[_-]?token|password)\s*[:=]\s*[""']?(?!\$\{\{|%|<|your-|example|dummy|placeholder|changeme|not-set|null|false|true)[A-Za-z0-9+/_=.\-]{24,}" # Apply the broad credential-assignment heuristic only to configuration-like files. $configExtensions = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($extension in @(".config", ".json", ".ps1", ".props", ".targets", ".yaml", ".yml")) { $configExtensions.Add($extension) | Out-Null } $findings = [System.Collections.Generic.List[string]]::new() $trackedFiles = @(Invoke-Git -Arguments @("ls-files")) # Keep findings free of matched values so credentials are never echoed to logs. function Add-SecretFinding { param( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [string] $Description ) $finding = "$Path $Description" $findings.Add($finding) } foreach ($relativePath in $trackedFiles) { if ([DateTimeOffset]::Now -gt $Deadline) { throw "Secret hygiene exceeded the configured timeout." } if ([string]::IsNullOrWhiteSpace($relativePath) -or $relativePath.StartsWith("LeptonX/", [StringComparison]::OrdinalIgnoreCase)) { continue } $fullPath = Join-Path $repoRoot $relativePath if (-not (Test-Path $fullPath)) { continue } $extension = [System.IO.Path]::GetExtension($relativePath) $fileName = [System.IO.Path]::GetFileName($relativePath) $isConfigLike = $configExtensions.Contains($extension) -or $fileName.StartsWith(".env", [StringComparison]::OrdinalIgnoreCase) # Skip large and binary files to keep the scan bounded and avoid decoding noise. $maxSizeBytes = 2MB $fileSize = (Get-Item -LiteralPath $fullPath -Force).Length if ($fileSize -gt $maxSizeBytes) { Write-Verbose "Secret hygiene skipping large file (>$($maxSizeBytes / 1MB)MB): $relativePath" continue } $content = Get-Content -LiteralPath $fullPath -Raw -Force -ErrorAction SilentlyContinue if ($null -eq $content -or $content.Contains("`0")) { continue } foreach ($patternName in $secretPatterns.Keys) { if ([regex]::IsMatch($content, $secretPatterns[$patternName])) { Add-SecretFinding -Path $relativePath -Description "contains $patternName." } } if ($isConfigLike -and [regex]::IsMatch($content, $credentialAssignmentPattern)) { Add-SecretFinding -Path $relativePath -Description "contains a credential-shaped assignment." } } if ($findings.Count -gt 0) { throw "Secret hygiene failed:`n$($findings -join "`n")" } } # Run all relative-path operations from the repository root and always restore the caller location. Push-Location $repoRoot try { Write-GateMessage "Mode: $gateModeName. Total timeout: $gateTotalTimeoutMinutes minute(s)." # Restore is normally mandatory; -NoRestore is an explicit optimized path for fresh assets. if ($NoRestore) { Write-GateMessage "Skipping restore because -NoRestore was supplied." } else { Invoke-GateStep "NuGet feed preflight" { Test-NuGetFeedConfiguration } Invoke-GateStep "Restore" { Invoke-DotNet ` -StepName "Restore" ` -Arguments @("restore", $solutionPath, "--verbosity", "minimal") ` -TimeoutKind "Restore" } } # Verify whitespace only in changed C# files to keep the inner loop focused and fast. Invoke-GateStep "Format verification" { $changedCSharpFiles = @(Get-ChangedCSharpFiles) if ($changedCSharpFiles.Count -eq 0) { Write-GateMessage "No changed C# files detected; skipping scoped format verification." } else { $formatBaseArguments = @( "format", "whitespace", $solutionPath, "--verify-no-changes", "--no-restore", "--verbosity", "minimal", "--include" ) $formatBatches = @(Split-LongArgumentList -BaseArguments $formatBaseArguments -Items $changedCSharpFiles) Write-GateMessage "Running scoped format verification for $($changedCSharpFiles.Count) changed C# file(s) in $($formatBatches.Count) batch(es)." for ($batchIndex = 0; $batchIndex -lt $formatBatches.Count; $batchIndex++) { $batchFiles = @($formatBatches[$batchIndex].Items) $batchNumber = $batchIndex + 1 Write-GateMessage "Format batch $batchNumber/$($formatBatches.Count): $($batchFiles.Count) file(s)." Invoke-DotNet ` -StepName "Format verification batch $batchNumber/$($formatBatches.Count)" ` -Arguments ($formatBaseArguments + $batchFiles) ` -TimeoutKind "Format" } } } # Quick mode builds only the architecture test dependency graph; other modes build everything. if ($Quick) { Invoke-GateStep "Build architecture gate" { Invoke-DotNet ` -StepName "Build architecture gate" ` -Arguments @("build", $architectureTestProject, "--configuration", $Configuration, "--no-restore", "-p:TreatWarningsAsErrors=false") ` -TimeoutKind "BuildArchitecture" } } else { Invoke-GateStep "Build solution" { Invoke-DotNet ` -StepName "Build solution" ` -Arguments @("build", $solutionPath, "--configuration", $Configuration, "--no-restore", "-p:TreatWarningsAsErrors=false") ` -TimeoutKind "BuildSolution" } } # Architecture rules run in every mode because they provide fast layer-boundary feedback. Invoke-GateStep "Architecture tests" { Invoke-DotNet ` -StepName "Architecture tests" ` -Arguments (Get-TestArguments -ProjectPath $architectureTestProject -TimeoutKind "ArchitectureTests") ` -TimeoutKind "ArchitectureTests" } # Default and Full modes add the fast unit/component suites and tracked-secret scan. if (-not $Quick) { foreach ($testProject in $fastTestProjects) { Invoke-GateStep "Fast tests: $testProject" { Invoke-DotNet ` -StepName "Fast tests: $testProject" ` -Arguments (Get-TestArguments -ProjectPath $testProject -TimeoutKind "FastTests") ` -TimeoutKind "FastTests" } } Invoke-GateStep "Secret hygiene" { $deadline = [DateTimeOffset]::Now.AddMinutes((Get-StepTimeoutMinutes -Kind "SecretHygiene")) Test-TrackedSecretHygiene -Deadline $deadline } } # Full mode adds slower suites and informational package health reports. if ($Full) { foreach ($testProject in $fullTestProjects) { Invoke-GateStep "Full tests: $testProject" { Invoke-DotNet ` -StepName "Full tests: $testProject" ` -Arguments (Get-TestArguments -ProjectPath $testProject -TimeoutKind "FullTests") ` -TimeoutKind "FullTests" } } if (-not $SkipIntegrationTests) { foreach ($testProject in $integrationTestProjects) { Invoke-GateStep "Integration tests: $testProject" { Invoke-DotNet ` -StepName "Integration tests: $testProject" ` -Arguments (Get-TestArguments -ProjectPath $testProject -TimeoutKind "FullTests") ` -TimeoutKind "FullTests" } } } else { Write-GateMessage "Skipping integration test projects because -SkipIntegrationTests was supplied." } Invoke-GateStep "Package vulnerability audit" { Invoke-PackageAuditReport ` -StepName "Package vulnerability audit" ` -Arguments @("list", $solutionPath, "package", "--vulnerable", "--include-transitive", "--no-restore") ` -FindingMarker "has the following vulnerable packages" } Invoke-GateStep "Package deprecation audit" { Invoke-PackageAuditReport ` -StepName "Package deprecation audit" ` -Arguments @("list", $solutionPath, "package", "--deprecated", "--no-restore", "--source", "https://api.nuget.org/v3/index.json") ` -FindingMarker "has the following deprecated packages" } } } finally { Pop-Location }