399 lines
15 KiB
PowerShell
399 lines
15 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Runs the complete portable Release gate with bounded child processes.
|
|
|
|
.DESCRIPTION
|
|
Discovers every test project under tests/ that declares itself through
|
|
Microsoft.NET.Test.Sdk or IsTestProject, verifies that AcDream.slnx owns it,
|
|
restores and builds the solution, and then runs each test assembly in a
|
|
fresh process. Every process has an outer hard timeout. Test processes also
|
|
use VSTest blame-hang collection, so a stuck test host is terminated with a
|
|
mini dump before the outer watchdog is allowed to kill the process tree.
|
|
|
|
The evidence directory contains process logs, one TRX per test assembly,
|
|
any blame sequence/dump files, an environment report, an aggregate JSON
|
|
summary, and SHA-256 hashes. The script never retries a failed test.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$ArtifactsDirectory = 'artifacts/release-gate',
|
|
[int]$RestoreTimeoutSeconds = 600,
|
|
[int]$BuildTimeoutSeconds = 900,
|
|
[int]$TestTimeoutSeconds = 600,
|
|
[int]$HangTimeoutSeconds = 180,
|
|
[switch]$SkipRestore,
|
|
[switch]$SkipBuild
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$repoRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
|
|
$solutionPath = Join-Path $repoRoot 'AcDream.slnx'
|
|
$artifactRoot = if ([IO.Path]::IsPathRooted($ArtifactsDirectory)) {
|
|
[IO.Path]::GetFullPath($ArtifactsDirectory)
|
|
} else {
|
|
[IO.Path]::GetFullPath((Join-Path $repoRoot $ArtifactsDirectory))
|
|
}
|
|
$logsDirectory = Join-Path $artifactRoot 'logs'
|
|
$resultsDirectory = Join-Path $artifactRoot 'test-results'
|
|
|
|
if ($RestoreTimeoutSeconds -le 0 -or $BuildTimeoutSeconds -le 0 -or
|
|
$TestTimeoutSeconds -le 0 -or $HangTimeoutSeconds -le 0) {
|
|
throw 'Every timeout must be a positive number of seconds.'
|
|
}
|
|
if ($TestTimeoutSeconds -le $HangTimeoutSeconds) {
|
|
throw 'TestTimeoutSeconds must exceed HangTimeoutSeconds so blame-hang can collect before the outer watchdog fires.'
|
|
}
|
|
if (-not (Test-Path -LiteralPath $solutionPath -PathType Leaf)) {
|
|
throw "Solution not found at '$solutionPath'."
|
|
}
|
|
|
|
# Reusing the documented local output path must not mix a passing run with
|
|
# stale dumps or TRX from an earlier failure. These are exact children of the
|
|
# caller-selected evidence root, never inferred filesystem targets.
|
|
foreach ($directory in @($logsDirectory, $resultsDirectory)) {
|
|
if (Test-Path -LiteralPath $directory) {
|
|
Remove-Item -LiteralPath $directory -Recurse -Force
|
|
}
|
|
}
|
|
foreach ($fileName in @('environment.txt', 'release-gate-summary.json', 'SHA256SUMS.txt')) {
|
|
$filePath = Join-Path $artifactRoot $fileName
|
|
if (Test-Path -LiteralPath $filePath -PathType Leaf) {
|
|
Remove-Item -LiteralPath $filePath -Force
|
|
}
|
|
}
|
|
New-Item -ItemType Directory -Force -Path $logsDirectory, $resultsDirectory | Out-Null
|
|
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
|
|
$env:DOTNET_NOLOGO = '1'
|
|
|
|
function ConvertTo-CommandText {
|
|
param([string[]]$Arguments)
|
|
|
|
$rendered = foreach ($argument in $Arguments) {
|
|
if ($argument -match '[\s\"]') {
|
|
'"' + $argument.Replace('"', '\"') + '"'
|
|
} else {
|
|
$argument
|
|
}
|
|
}
|
|
return 'dotnet ' + ($rendered -join ' ')
|
|
}
|
|
|
|
function Invoke-BoundedDotnet {
|
|
param(
|
|
[Parameter(Mandatory)] [string]$Label,
|
|
[Parameter(Mandatory)] [string[]]$Arguments,
|
|
[Parameter(Mandatory)] [int]$TimeoutSeconds
|
|
)
|
|
|
|
$command = ConvertTo-CommandText -Arguments $Arguments
|
|
$logPath = Join-Path $logsDirectory "$Label.log"
|
|
$startedUtc = [DateTimeOffset]::UtcNow
|
|
Write-Host "[$Label] $command"
|
|
|
|
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
|
$startInfo.FileName = 'dotnet'
|
|
$startInfo.WorkingDirectory = $repoRoot
|
|
$startInfo.UseShellExecute = $false
|
|
$startInfo.RedirectStandardOutput = $true
|
|
$startInfo.RedirectStandardError = $true
|
|
foreach ($argument in $Arguments) {
|
|
$startInfo.ArgumentList.Add($argument)
|
|
}
|
|
|
|
$process = [Diagnostics.Process]::new()
|
|
$process.StartInfo = $startInfo
|
|
if (-not $process.Start()) {
|
|
throw "[$Label] failed to start dotnet."
|
|
}
|
|
|
|
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
|
|
$standardErrorTask = $process.StandardError.ReadToEndAsync()
|
|
$completed = $process.WaitForExit($TimeoutSeconds * 1000)
|
|
$timedOut = -not $completed
|
|
if ($timedOut) {
|
|
Write-Error "[$Label] exceeded its hard ${TimeoutSeconds}s timeout; killing PID $($process.Id) and its descendants." -ErrorAction Continue
|
|
try {
|
|
$process.Kill($true)
|
|
} catch {
|
|
Write-Error "[$Label] process-tree kill failed: $($_.Exception.Message)" -ErrorAction Continue
|
|
}
|
|
}
|
|
|
|
if ($timedOut -and -not $process.WaitForExit(30000)) {
|
|
throw "[$Label] did not exit within 30 seconds after process-tree termination was requested."
|
|
}
|
|
if (-not $timedOut) {
|
|
$process.WaitForExit()
|
|
}
|
|
$standardOutput = $standardOutputTask.GetAwaiter().GetResult()
|
|
$standardError = $standardErrorTask.GetAwaiter().GetResult()
|
|
$endedUtc = [DateTimeOffset]::UtcNow
|
|
$exitCode = if ($timedOut) { 124 } else { $process.ExitCode }
|
|
|
|
$log = @(
|
|
"label: $Label"
|
|
"command: $command"
|
|
"startedUtc: $($startedUtc.ToString('O'))"
|
|
"endedUtc: $($endedUtc.ToString('O'))"
|
|
"durationSeconds: $([Math]::Round(($endedUtc - $startedUtc).TotalSeconds, 3))"
|
|
"hardTimeoutSeconds: $TimeoutSeconds"
|
|
"timedOut: $timedOut"
|
|
"exitCode: $exitCode"
|
|
''
|
|
'--- stdout ---'
|
|
$standardOutput
|
|
'--- stderr ---'
|
|
$standardError
|
|
)
|
|
Set-Content -LiteralPath $logPath -Encoding utf8 -Value $log
|
|
|
|
if ($standardOutput) {
|
|
Write-Host $standardOutput.TrimEnd()
|
|
}
|
|
if ($standardError) {
|
|
Write-Host $standardError.TrimEnd()
|
|
}
|
|
|
|
$result = [pscustomobject]@{
|
|
Label = $Label
|
|
Command = $command
|
|
StartedUtc = $startedUtc.ToString('O')
|
|
EndedUtc = $endedUtc.ToString('O')
|
|
DurationSeconds = [Math]::Round(($endedUtc - $startedUtc).TotalSeconds, 3)
|
|
HardTimeoutSeconds = $TimeoutSeconds
|
|
TimedOut = $timedOut
|
|
ExitCode = $exitCode
|
|
Log = [IO.Path]::GetRelativePath($artifactRoot, $logPath).Replace('\', '/')
|
|
}
|
|
$process.Dispose()
|
|
return $result
|
|
}
|
|
|
|
function Get-TestProjects {
|
|
$solutionText = Get-Content -LiteralPath $solutionPath -Raw
|
|
$projects = @(
|
|
Get-ChildItem -LiteralPath (Join-Path $repoRoot 'tests') -Recurse -Filter '*.csproj' -File |
|
|
Where-Object {
|
|
$projectText = Get-Content -LiteralPath $_.FullName -Raw
|
|
$projectText -match 'Microsoft\.NET\.Test\.Sdk' -or
|
|
$projectText -match '<IsTestProject>\s*true\s*</IsTestProject>'
|
|
} |
|
|
Sort-Object FullName
|
|
)
|
|
|
|
if ($projects.Count -eq 0) {
|
|
throw 'No test projects were discovered.'
|
|
}
|
|
|
|
foreach ($project in $projects) {
|
|
$relative = [IO.Path]::GetRelativePath($repoRoot, $project.FullName).Replace('\', '/')
|
|
if ($solutionText -notmatch [Regex]::Escape("Path=`"$relative`"")) {
|
|
throw "Discovered test project '$relative' is missing from AcDream.slnx."
|
|
}
|
|
}
|
|
|
|
return $projects
|
|
}
|
|
|
|
function Get-TrxCounters {
|
|
param([Parameter(Mandatory)] [string]$TrxPath)
|
|
|
|
[xml]$document = Get-Content -LiteralPath $TrxPath
|
|
$counters = $document.TestRun.ResultSummary.Counters
|
|
if ($null -eq $counters) {
|
|
throw "TRX '$TrxPath' has no ResultSummary/Counters element."
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Total = [int]$counters.total
|
|
Executed = [int]$counters.executed
|
|
Passed = [int]$counters.passed
|
|
Failed = [int]$counters.failed
|
|
# xUnit v3 records skips in TRX as total minus executed while leaving
|
|
# the VSTest notExecuted attribute at zero. There is no filter in this
|
|
# gate, so that difference is the assembly's reported skip count.
|
|
Skipped = [int]$counters.total - [int]$counters.executed
|
|
Error = [int]$counters.error
|
|
Timeout = [int]$counters.timeout
|
|
Aborted = [int]$counters.aborted
|
|
NotRunnable = [int]$counters.notRunnable
|
|
}
|
|
}
|
|
|
|
function Write-ArtifactHashes {
|
|
$hashPath = Join-Path $artifactRoot 'SHA256SUMS.txt'
|
|
$lines = @(
|
|
Get-ChildItem -LiteralPath $artifactRoot -Recurse -File |
|
|
Where-Object FullName -ne $hashPath |
|
|
Sort-Object FullName |
|
|
ForEach-Object {
|
|
$relative = [IO.Path]::GetRelativePath($artifactRoot, $_.FullName).Replace('\', '/')
|
|
$hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
"$hash $relative"
|
|
}
|
|
)
|
|
Set-Content -LiteralPath $hashPath -Encoding ascii -Value $lines
|
|
}
|
|
|
|
Push-Location $repoRoot
|
|
try {
|
|
$gateStartedUtc = [DateTimeOffset]::UtcNow
|
|
$testProjects = @(Get-TestProjects)
|
|
$processes = [Collections.Generic.List[object]]::new()
|
|
$testReports = [Collections.Generic.List[object]]::new()
|
|
$gateFailures = [Collections.Generic.List[string]]::new()
|
|
$worktreeStatus = @(git status --porcelain=v1 --untracked-files=all)
|
|
$worktreeDirty = $worktreeStatus.Count -ne 0
|
|
|
|
$environmentPath = Join-Path $artifactRoot 'environment.txt'
|
|
$environmentReport = @(
|
|
"commit: $(git rev-parse HEAD)"
|
|
"branch: $(git branch --show-current)"
|
|
"worktreeDirty: $worktreeDirty"
|
|
"runtimeIdentifier: $([Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier)"
|
|
"os: $([Runtime.InteropServices.RuntimeInformation]::OSDescription)"
|
|
"processArchitecture: $([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)"
|
|
"powershell: $($PSVersionTable.PSVersion)"
|
|
''
|
|
'--- dotnet --info ---'
|
|
(dotnet --info | Out-String)
|
|
'--- configured NuGet sources ---'
|
|
(dotnet nuget list source | Out-String)
|
|
'--- worktree status at gate start ---'
|
|
($worktreeStatus -join [Environment]::NewLine)
|
|
'--- discovered test projects ---'
|
|
($testProjects | ForEach-Object {
|
|
[IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/')
|
|
})
|
|
)
|
|
Set-Content -LiteralPath $environmentPath -Encoding utf8 -Value $environmentReport
|
|
|
|
if (-not $SkipRestore) {
|
|
$restore = Invoke-BoundedDotnet -Label 'restore' -TimeoutSeconds $RestoreTimeoutSeconds -Arguments @(
|
|
'restore', $solutionPath, '--nologo'
|
|
)
|
|
$processes.Add($restore)
|
|
if ($restore.ExitCode -ne 0) {
|
|
throw "Restore failed or timed out; see '$($restore.Log)'."
|
|
}
|
|
}
|
|
|
|
if (-not $SkipBuild) {
|
|
$buildArguments = @('build', $solutionPath, '-c', 'Release', '--nologo')
|
|
if (-not $SkipRestore) {
|
|
$buildArguments += '--no-restore'
|
|
}
|
|
$build = Invoke-BoundedDotnet -Label 'build' -TimeoutSeconds $BuildTimeoutSeconds -Arguments $buildArguments
|
|
$processes.Add($build)
|
|
if ($build.ExitCode -ne 0) {
|
|
throw "Build failed or timed out; see '$($build.Log)'."
|
|
}
|
|
}
|
|
|
|
foreach ($project in $testProjects) {
|
|
$projectName = [IO.Path]::GetFileNameWithoutExtension($project.Name)
|
|
$projectResultDirectory = Join-Path $resultsDirectory $projectName
|
|
New-Item -ItemType Directory -Force -Path $projectResultDirectory | Out-Null
|
|
$relativeProject = [IO.Path]::GetRelativePath($repoRoot, $project.FullName).Replace('\', '/')
|
|
$test = Invoke-BoundedDotnet -Label "test-$projectName" -TimeoutSeconds $TestTimeoutSeconds -Arguments @(
|
|
'test'
|
|
$project.FullName
|
|
'-c'
|
|
'Release'
|
|
'--no-restore'
|
|
'--no-build'
|
|
'--nologo'
|
|
'--results-directory'
|
|
$projectResultDirectory
|
|
'--logger'
|
|
"trx;LogFileName=$projectName.trx"
|
|
'--logger'
|
|
'console;verbosity=minimal'
|
|
'--blame-hang'
|
|
'--blame-hang-dump-type'
|
|
'mini'
|
|
'--blame-hang-timeout'
|
|
"${HangTimeoutSeconds}s"
|
|
)
|
|
$processes.Add($test)
|
|
|
|
$trxPath = Join-Path $projectResultDirectory "$projectName.trx"
|
|
$counters = if (Test-Path -LiteralPath $trxPath -PathType Leaf) {
|
|
Get-TrxCounters -TrxPath $trxPath
|
|
} else {
|
|
$null
|
|
}
|
|
$testReports.Add([pscustomobject]@{
|
|
Project = $relativeProject
|
|
Assembly = $projectName
|
|
Process = $test
|
|
Trx = if ($null -ne $counters) {
|
|
[IO.Path]::GetRelativePath($artifactRoot, $trxPath).Replace('\', '/')
|
|
} else {
|
|
$null
|
|
}
|
|
Counters = $counters
|
|
})
|
|
|
|
if ($test.ExitCode -ne 0) {
|
|
$gateFailures.Add("$projectName exited $($test.ExitCode) (timedOut=$($test.TimedOut)).")
|
|
}
|
|
if ($null -eq $counters) {
|
|
$gateFailures.Add("$projectName produced no TRX result.")
|
|
}
|
|
}
|
|
|
|
$totals = [pscustomobject]@{
|
|
Total = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Total } } | Measure-Object -Sum).Sum
|
|
Executed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Executed } } | Measure-Object -Sum).Sum
|
|
Passed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Passed } } | Measure-Object -Sum).Sum
|
|
Failed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Failed } } | Measure-Object -Sum).Sum
|
|
Skipped = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Skipped } } | Measure-Object -Sum).Sum
|
|
}
|
|
$gateEndedUtc = [DateTimeOffset]::UtcNow
|
|
$summary = [ordered]@{
|
|
SchemaVersion = 1
|
|
Verdict = if ($gateFailures.Count -eq 0) { 'passed' } else { 'failed' }
|
|
Commit = (git rev-parse HEAD)
|
|
Branch = (git branch --show-current)
|
|
WorktreeDirty = $worktreeDirty
|
|
SdkVersion = (dotnet --version)
|
|
RuntimeIdentifier = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
|
|
StartedUtc = $gateStartedUtc.ToString('O')
|
|
EndedUtc = $gateEndedUtc.ToString('O')
|
|
DurationSeconds = [Math]::Round(($gateEndedUtc - $gateStartedUtc).TotalSeconds, 3)
|
|
Solution = 'AcDream.slnx'
|
|
TestProjectCount = $testProjects.Count
|
|
Timeouts = [ordered]@{
|
|
RestoreSeconds = $RestoreTimeoutSeconds
|
|
BuildSeconds = $BuildTimeoutSeconds
|
|
TestProcessSeconds = $TestTimeoutSeconds
|
|
PerTestHangSeconds = $HangTimeoutSeconds
|
|
}
|
|
Totals = $totals
|
|
Failures = @($gateFailures)
|
|
Tests = @($testReports)
|
|
Processes = @($processes)
|
|
Evidence = [ordered]@{
|
|
Environment = 'environment.txt'
|
|
Hashes = 'SHA256SUMS.txt'
|
|
}
|
|
}
|
|
$summaryPath = Join-Path $artifactRoot 'release-gate-summary.json'
|
|
$summary | ConvertTo-Json -Depth 12 |
|
|
Set-Content -LiteralPath $summaryPath -Encoding utf8
|
|
Write-ArtifactHashes
|
|
|
|
Write-Host "Release gate $($summary.Verdict): $($totals.Passed) passed, $($totals.Skipped) skipped, $($totals.Failed) failed across $($testProjects.Count) test assemblies."
|
|
Write-Host "Evidence: $artifactRoot"
|
|
if ($gateFailures.Count -ne 0) {
|
|
foreach ($failure in $gateFailures) {
|
|
Write-Error $failure -ErrorAction Continue
|
|
}
|
|
exit 1
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
}
|