diff --git a/scripts/Test-WinChessRelease.ps1 b/scripts/Test-WinChessRelease.ps1 new file mode 100644 index 0000000..cbeb560 --- /dev/null +++ b/scripts/Test-WinChessRelease.ps1 @@ -0,0 +1,1374 @@ +[CmdletBinding()] +param( + [ValidateSet('Auto', 'PreRelease', 'PostRelease')] + [string]$Mode = 'Auto', + + [ValidatePattern('^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$')] + [string]$Version = '0.1.0', + + [string]$RepoPath = 'D:\WinChess', + + [string]$Repository = 'binarylab2022-del/WinChess', + + [switch]$SkipBuild, + + [switch]$KeepDownloadedRelease +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Tag = "v$Version" +$Results = New-Object System.Collections.Generic.List[object] +$FailureCount = 0 +$WarningCount = 0 +$PassCount = 0 + +function Add-CheckResult { + param( + [Parameter(Mandatory = $true)] + [string]$Category, + + [Parameter(Mandatory = $true)] + [string]$Check, + + [Parameter(Mandatory = $true)] + [ValidateSet('PASS', 'WARN', 'FAIL')] + [string]$Status, + + [string]$Details = '' + ) + + $script:Results.Add([pscustomobject]@{ + Category = $Category + Check = $Check + Status = $Status + Details = $Details + }) + + switch ($Status) { + 'PASS' { + $script:PassCount++ + Write-Host "[PASS] $Check" -ForegroundColor Green + } + 'WARN' { + $script:WarningCount++ + Write-Host "[WARN] $Check" -ForegroundColor Yellow + } + 'FAIL' { + $script:FailureCount++ + Write-Host "[FAIL] $Check" -ForegroundColor Red + } + } + + if (-not [string]::IsNullOrWhiteSpace($Details)) { + Write-Host " $Details" -ForegroundColor DarkGray + } +} + +function Test-ExternalCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Invoke-CapturedCommand { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Command + ) + + $output = & $Command 2>&1 + $exitCode = $LASTEXITCODE + + return [pscustomobject]@{ + Output = @($output) + ExitCode = $exitCode + } +} + +function Get-CompatibleRelativePath { + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$BasePath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$TargetPath + ) + + $baseFullPath = [IO.Path]::GetFullPath($BasePath) + $targetFullPath = [IO.Path]::GetFullPath($TargetPath) + + $trimCharacters = [char[]]@( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + + $basePrefix = $baseFullPath.TrimEnd($trimCharacters) + + [IO.Path]::DirectorySeparatorChar + + if (-not $targetFullPath.StartsWith( + $basePrefix, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Path outside base directory: $targetFullPath" + } + + return $targetFullPath.Substring($basePrefix.Length) +} + +function Test-RequiredFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (Test-Path -LiteralPath $Path -PathType Leaf) { + Add-CheckResult ` + -Category 'Repository' ` + -Check $Label ` + -Status 'PASS' ` + -Details $Path + + return $true + } + + Add-CheckResult ` + -Category 'Repository' ` + -Check $Label ` + -Status 'FAIL' ` + -Details "Missing file: $Path" + + return $false +} + +function Test-PackageChecksums { + param( + [Parameter(Mandatory = $true)] + [string]$StagePath + ) + + $sumFile = Join-Path $StagePath 'SHA256SUMS.txt' + + if (-not (Test-Path -LiteralPath $sumFile -PathType Leaf)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Internal SHA-256 manifest exists' ` + -Status 'FAIL' ` + -Details $sumFile + + return + } + + Add-CheckResult ` + -Category 'Package' ` + -Check 'Internal SHA-256 manifest exists' ` + -Status 'PASS' ` + -Details $sumFile + + $badLines = 0 + $missingFiles = 0 + $mismatches = 0 + $checked = 0 + + foreach ($line in Get-Content -LiteralPath $sumFile) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + if ($line -notmatch '^([0-9a-fA-F]{64}) \*(.+)$') { + $badLines++ + continue + } + + $expectedHash = $Matches[1].ToLowerInvariant() + $relativePath = $Matches[2] + $filePath = Join-Path $StagePath $relativePath + + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { + $missingFiles++ + continue + } + + $actualHash = ( + Get-FileHash ` + -LiteralPath $filePath ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + + $checked++ + + if ($actualHash -ne $expectedHash) { + $mismatches++ + } + } + + if ($badLines -eq 0 -and $missingFiles -eq 0 -and $mismatches -eq 0) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Internal package checksums are valid' ` + -Status 'PASS' ` + -Details "$checked file(s) verified." + + return + } + + Add-CheckResult ` + -Category 'Package' ` + -Check 'Internal package checksums are valid' ` + -Status 'FAIL' ` + -Details "Checked=$checked; malformed=$badLines; missing=$missingFiles; mismatches=$mismatches." +} + +function Test-ZipChecksum { + param( + [Parameter(Mandatory = $true)] + [string]$ZipPath, + + [Parameter(Mandatory = $true)] + [string]$ChecksumPath + ) + + if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Release ZIP exists' ` + -Status 'FAIL' ` + -Details $ZipPath + + return + } + + Add-CheckResult ` + -Category 'Package' ` + -Check 'Release ZIP exists' ` + -Status 'PASS' ` + -Details $ZipPath + + if (-not (Test-Path -LiteralPath $ChecksumPath -PathType Leaf)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'ZIP checksum file exists' ` + -Status 'FAIL' ` + -Details $ChecksumPath + + return + } + + Add-CheckResult ` + -Category 'Package' ` + -Check 'ZIP checksum file exists' ` + -Status 'PASS' ` + -Details $ChecksumPath + + $checksumLine = Get-Content ` + -LiteralPath $ChecksumPath | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($checksumLine)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'ZIP checksum is valid' ` + -Status 'FAIL' ` + -Details 'Checksum file is empty.' + + return + } + + $expectedHash = ($checksumLine -split '\s+')[0].ToLowerInvariant() + $actualHash = ( + Get-FileHash ` + -LiteralPath $ZipPath ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + + if ($expectedHash -eq $actualHash) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'ZIP checksum is valid' ` + -Status 'PASS' ` + -Details $actualHash + } + else { + Add-CheckResult ` + -Category 'Package' ` + -Check 'ZIP checksum is valid' ` + -Status 'FAIL' ` + -Details "Expected=$expectedHash; actual=$actualHash." + } +} + +function Test-PackagedUci { + param( + [Parameter(Mandatory = $true)] + [string]$UciPath + ) + + if (-not (Test-Path -LiteralPath $UciPath -PathType Leaf)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Packaged UCI executable exists' ` + -Status 'FAIL' ` + -Details $UciPath + + return + } + + $uciOutput = @" +uci +isready +quit +"@ | & $UciPath 2>&1 + + if ($LASTEXITCODE -ne 0) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Packaged UCI smoke test' ` + -Status 'FAIL' ` + -Details "Exit code: $LASTEXITCODE." + + return + } + + $hasUciOk = $uciOutput -contains 'uciok' + $hasReadyOk = $uciOutput -contains 'readyok' + + if ($hasUciOk -and $hasReadyOk) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Packaged UCI smoke test' ` + -Status 'PASS' ` + -Details 'uciok and readyok returned.' + } + else { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Packaged UCI smoke test' ` + -Status 'FAIL' ` + -Details "uciok=$hasUciOk; readyok=$hasReadyOk." + } +} + +function Test-LocalRepository { + Write-Host "`n=== Local repository checks ===" -ForegroundColor Cyan + + if (-not (Test-Path -LiteralPath $RepoPath -PathType Container)) { + Add-CheckResult ` + -Category 'Repository' ` + -Check 'Repository directory exists' ` + -Status 'FAIL' ` + -Details $RepoPath + + return $false + } + + Add-CheckResult ` + -Category 'Repository' ` + -Check 'Repository directory exists' ` + -Status 'PASS' ` + -Details $RepoPath + + Set-Location $RepoPath + + if (-not (Test-Path -LiteralPath '.git' -PathType Container)) { + Add-CheckResult ` + -Category 'Repository' ` + -Check 'Git repository detected' ` + -Status 'FAIL' ` + -Details "$RepoPath is not a Git working tree." + + return $false + } + + Add-CheckResult ` + -Category 'Repository' ` + -Check 'Git repository detected' ` + -Status 'PASS' + + $requiredFiles = @( + @{ Path = 'ChessProject.slnx'; Label = 'Solution file exists' }, + @{ Path = 'ChessGUI\ChessGUI.csproj'; Label = 'WPF project exists' }, + @{ Path = 'scripts\Publish-Release.ps1'; Label = 'Release script exists' }, + @{ Path = '.github\workflows\release.yml'; Label = 'Release workflow exists' }, + @{ Path = 'LICENSE'; Label = 'License exists' }, + @{ Path = 'README.md'; Label = 'README exists' }, + @{ Path = 'AUTHORS.md'; Label = 'Authors file exists' }, + @{ Path = 'CONTRIBUTING.md'; Label = 'Contributing guide exists' }, + @{ Path = 'CODE_OF_CONDUCT.md'; Label = 'Code of conduct exists' }, + @{ Path = 'SECURITY.md'; Label = 'Security policy exists' }, + @{ Path = 'RELEASE_NOTES.md'; Label = 'Release notes exist' } + ) + + foreach ($item in $requiredFiles) { + Test-RequiredFile ` + -Path (Join-Path $RepoPath $item.Path) ` + -Label $item.Label | + Out-Null + } + + return $true +} + +function Test-GitState { + Write-Host "`n=== Git state checks ===" -ForegroundColor Cyan + + $fetchResult = Invoke-CapturedCommand { + git fetch origin --prune --tags + } + + if ($fetchResult.ExitCode -eq 0) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Origin fetched successfully' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Origin fetched successfully' ` + -Status 'FAIL' ` + -Details ($fetchResult.Output -join ' ') + + return + } + + $branch = (git branch --show-current).Trim() + + if ($branch -eq 'main') { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Current branch is main' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Current branch is main' ` + -Status 'FAIL' ` + -Details "Current branch: $branch" + } + + $status = @(git status --short) + + $selfRelativePath = $null + + if (-not [string]::IsNullOrWhiteSpace($PSCommandPath)) { + try { + $selfRelativePath = Get-CompatibleRelativePath ` + -BasePath $RepoPath ` + -TargetPath $PSCommandPath + + $selfRelativePath = $selfRelativePath.Replace('\', '/') + } + catch { + $selfRelativePath = $null + } + } + + $selfOnlyStatus = @() + + if ($null -ne $selfRelativePath) { + $escapedSelfPath = [regex]::Escape($selfRelativePath) + + $selfOnlyStatus = @( + $status | + Where-Object { + $_ -match "^\?\?\s+$escapedSelfPath$" + } + ) + } + + $otherStatus = @( + $status | + Where-Object { + $selfOnlyStatus -notcontains $_ + } + ) + + if ($status.Count -eq 0) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Working tree is clean' ` + -Status 'PASS' + } + elseif ( + $otherStatus.Count -eq 0 -and + $selfOnlyStatus.Count -eq 1 + ) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Working tree is clean' ` + -Status 'WARN' ` + -Details "Only the verifier script is untracked: $selfRelativePath. Add and commit it to obtain a fully clean tree." + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Working tree is clean' ` + -Status 'FAIL' ` + -Details ($status -join '; ') + } + + $localMain = (git rev-parse HEAD).Trim() + $remoteResult = Invoke-CapturedCommand { + git rev-parse origin/main + } + + if ($remoteResult.ExitCode -ne 0) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'origin/main exists' ` + -Status 'FAIL' ` + -Details ($remoteResult.Output -join ' ') + + return + } + + $remoteMain = ($remoteResult.Output | Select-Object -First 1).ToString().Trim() + + if ($localMain -eq $remoteMain) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Local main matches origin/main' ` + -Status 'PASS' ` + -Details $localMain + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Local main matches origin/main' ` + -Status 'FAIL' ` + -Details "Local=$localMain; remote=$remoteMain." + } + + $trackedArtifacts = @(git ls-files artifacts) + + if ($trackedArtifacts.Count -eq 0) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Generated artifacts are not tracked' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'Generated artifacts are not tracked' ` + -Status 'FAIL' ` + -Details ($trackedArtifacts -join '; ') + } + + git check-ignore -q 'artifacts/readiness-test.tmp' + + if ($LASTEXITCODE -eq 0) { + Add-CheckResult ` + -Category 'Git' ` + -Check 'artifacts directory is ignored' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Git' ` + -Check 'artifacts directory is ignored' ` + -Status 'WARN' ` + -Details 'Add artifacts/ to .gitignore.' + } +} + +function Test-ReleaseConfiguration { + Write-Host "`n=== Release configuration checks ===" -ForegroundColor Cyan + + $workflowPath = Join-Path $RepoPath '.github\workflows\release.yml' + $publishScriptPath = Join-Path $RepoPath 'scripts\Publish-Release.ps1' + $projectPath = Join-Path $RepoPath 'ChessGUI\ChessGUI.csproj' + + $workflowText = Get-Content ` + -LiteralPath $workflowPath ` + -Raw + + $expectedActions = @( + 'actions/checkout@v7', + 'actions/setup-dotnet@v6', + 'microsoft/setup-msbuild@v3', + 'actions/upload-artifact@v7' + ) + + foreach ($action in $expectedActions) { + if ($workflowText -match [regex]::Escape($action)) { + Add-CheckResult ` + -Category 'Workflow' ` + -Check "Workflow uses $action" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Workflow' ` + -Check "Workflow uses $action" ` + -Status 'FAIL' + } + } + + foreach ($requiredText in @( + 'workflow_dispatch', + 'permissions:', + 'contents: write', + 'Publish-Release.ps1', + 'gh release' + )) { + if ($workflowText -match [regex]::Escape($requiredText)) { + Add-CheckResult ` + -Category 'Workflow' ` + -Check "Workflow contains '$requiredText'" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Workflow' ` + -Check "Workflow contains '$requiredText'" ` + -Status 'FAIL' + } + } + + $hasZipChecksumReference = ( + $workflowText -match '\$zip\.sha256' -or + $workflowText -match '\.zip\.sha256' + ) + + if ($hasZipChecksumReference) { + Add-CheckResult ` + -Category 'Workflow' ` + -Check 'Workflow references the ZIP SHA-256 file' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Workflow' ` + -Check 'Workflow references the ZIP SHA-256 file' ` + -Status 'FAIL' + } + + $publishText = Get-Content ` + -LiteralPath $publishScriptPath ` + -Raw + + foreach ($forbiddenPattern in @( + 'dotnet publish', + '[IO.Path]::GetRelativePath', + 'ChessGUI.exe' + )) { + if ($publishText -match [regex]::Escape($forbiddenPattern)) { + Add-CheckResult ` + -Category 'Script' ` + -Check "Release script excludes '$forbiddenPattern'" ` + -Status 'FAIL' ` + -Details 'Forbidden legacy code is still present.' + } + else { + Add-CheckResult ` + -Category 'Script' ` + -Check "Release script excludes '$forbiddenPattern'" ` + -Status 'PASS' + } + } + + foreach ($requiredPattern in @( + 'Resolve-MSBuild', + 'Resolve-ProjectAssemblyName', + 'Get-CompatibleRelativePath', + '/t:Publish', + 'WinChessUCI.exe', + 'SHA256SUMS.txt' + )) { + if ($publishText -match [regex]::Escape($requiredPattern)) { + Add-CheckResult ` + -Category 'Script' ` + -Check "Release script contains '$requiredPattern'" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Script' ` + -Check "Release script contains '$requiredPattern'" ` + -Status 'FAIL' + } + } + + try { + [xml]$projectXml = Get-Content ` + -LiteralPath $projectPath ` + -Raw + + $assemblyNode = $projectXml.SelectSingleNode( + '/Project/PropertyGroup/AssemblyName' + ) + + if ( + $null -ne $assemblyNode -and + $assemblyNode.InnerText.Trim() -eq 'WinChess' + ) { + Add-CheckResult ` + -Category 'Project' ` + -Check 'GUI assembly name is WinChess' ` + -Status 'PASS' + } + elseif ($null -eq $assemblyNode) { + Add-CheckResult ` + -Category 'Project' ` + -Check 'GUI assembly name is WinChess' ` + -Status 'WARN' ` + -Details 'AssemblyName is implicit; the release script must resolve it safely.' + } + else { + Add-CheckResult ` + -Category 'Project' ` + -Check 'GUI assembly name is WinChess' ` + -Status 'FAIL' ` + -Details "AssemblyName=$($assemblyNode.InnerText.Trim())" + } + } + catch { + Add-CheckResult ` + -Category 'Project' ` + -Check 'ChessGUI.csproj XML is readable' ` + -Status 'FAIL' ` + -Details $_.Exception.Message + } +} + +function Test-LocalPackage { + Write-Host "`n=== Local build and package checks ===" -ForegroundColor Cyan + + $publishScript = Join-Path $RepoPath 'scripts\Publish-Release.ps1' + $artifacts = Join-Path $RepoPath 'artifacts' + $packageName = "WinChess-$Version-win-x64" + $stage = Join-Path $artifacts $packageName + $zip = Join-Path $artifacts "$packageName.zip" + $zipChecksum = "$zip.sha256" + + if ($SkipBuild) { + Add-CheckResult ` + -Category 'Build' ` + -Check 'Local release build executed' ` + -Status 'WARN' ` + -Details 'Skipped by -SkipBuild.' + } + else { + try { + & $publishScript -Version $Version + + Add-CheckResult ` + -Category 'Build' ` + -Check 'Local release build executed' ` + -Status 'PASS' + } + catch { + Add-CheckResult ` + -Category 'Build' ` + -Check 'Local release build executed' ` + -Status 'FAIL' ` + -Details $_.Exception.Message + + return + } + } + + if (-not (Test-Path -LiteralPath $stage -PathType Container)) { + Add-CheckResult ` + -Category 'Package' ` + -Check 'Staging directory exists' ` + -Status 'FAIL' ` + -Details $stage + + return + } + + Add-CheckResult ` + -Category 'Package' ` + -Check 'Staging directory exists' ` + -Status 'PASS' ` + -Details $stage + + $requiredPackageFiles = @( + 'WinChess.exe', + 'WinChess.dll', + 'ChessDLL.dll', + 'WinChessUCI.exe', + 'LICENSE', + 'VERSION.txt', + 'SHA256SUMS.txt' + ) + + foreach ($fileName in $requiredPackageFiles) { + $filePath = Join-Path $stage $fileName + + if (Test-Path -LiteralPath $filePath -PathType Leaf) { + Add-CheckResult ` + -Category 'Package' ` + -Check "Package contains $fileName" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Package' ` + -Check "Package contains $fileName" ` + -Status 'FAIL' ` + -Details $filePath + } + } + + Test-PackageChecksums -StagePath $stage + Test-ZipChecksum -ZipPath $zip -ChecksumPath $zipChecksum + Test-PackagedUci -UciPath (Join-Path $stage 'WinChessUCI.exe') +} + +function Get-RemoteReleaseState { + $tagResult = Invoke-CapturedCommand { + git ls-remote --tags origin "refs/tags/$Tag" + } + + $tagExists = ( + $tagResult.ExitCode -eq 0 -and + ($tagResult.Output -join '').Trim().Length -gt 0 + ) + + $releaseResult = Invoke-CapturedCommand { + gh release view $Tag ` + --repo $Repository ` + --json tagName,name,isDraft,isPrerelease,url,assets + } + + $releaseExists = $releaseResult.ExitCode -eq 0 + $releaseData = $null + + if ($releaseExists) { + try { + $releaseData = ( + $releaseResult.Output -join [Environment]::NewLine + ) | ConvertFrom-Json + } + catch { + $releaseExists = $false + } + } + + return [pscustomobject]@{ + TagExists = $tagExists + ReleaseExists = $releaseExists + ReleaseData = $releaseData + } +} + +function Resolve-EffectiveMode { + param( + [Parameter(Mandatory = $true)] + [object]$RemoteState + ) + + if ($Mode -ne 'Auto') { + return $Mode + } + + if (-not $RemoteState.TagExists -and -not $RemoteState.ReleaseExists) { + return 'PreRelease' + } + + if ($RemoteState.TagExists -and $RemoteState.ReleaseExists) { + return 'PostRelease' + } + + return 'Inconsistent' +} + +function Test-PreReleaseRemoteState { + param( + [Parameter(Mandatory = $true)] + [object]$RemoteState + ) + + Write-Host "`n=== Pre-release remote checks ===" -ForegroundColor Cyan + + if (-not $RemoteState.TagExists) { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Remote tag $Tag is absent" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Remote tag $Tag is absent" ` + -Status 'FAIL' ` + -Details "Delete it with: git push origin --delete $Tag" + } + + if (-not $RemoteState.ReleaseExists) { + Add-CheckResult ` + -Category 'Remote' ` + -Check "GitHub release $Tag is absent" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "GitHub release $Tag is absent" ` + -Status 'FAIL' ` + -Details "Delete it with: gh release delete $Tag --repo $Repository --yes" + } +} + +function Test-PostReleaseRemoteState { + param( + [Parameter(Mandatory = $true)] + [object]$RemoteState + ) + + Write-Host "`n=== Published release checks ===" -ForegroundColor Cyan + + if ($RemoteState.TagExists) { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Remote tag $Tag exists" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Remote tag $Tag exists" ` + -Status 'FAIL' + } + + if (-not $RemoteState.ReleaseExists) { + Add-CheckResult ` + -Category 'Remote' ` + -Check "GitHub release $Tag exists" ` + -Status 'FAIL' + + return + } + + Add-CheckResult ` + -Category 'Remote' ` + -Check "GitHub release $Tag exists" ` + -Status 'PASS' ` + -Details $RemoteState.ReleaseData.url + + if (-not $RemoteState.ReleaseData.isDraft) { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Release is not a draft' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Release is not a draft' ` + -Status 'FAIL' + } + + if (-not $RemoteState.ReleaseData.isPrerelease) { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Release is not a prerelease' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Release is not a prerelease' ` + -Status 'FAIL' + } + + $assetNames = @($RemoteState.ReleaseData.assets | ForEach-Object { + $_.name + }) + + $expectedAssets = @( + "WinChess-$Version-win-x64.zip", + "WinChess-$Version-win-x64.zip.sha256" + ) + + foreach ($assetName in $expectedAssets) { + if ($assetNames -contains $assetName) { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Release contains $assetName" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "Release contains $assetName" ` + -Status 'FAIL' + } + } + + $tagCommitResult = Invoke-CapturedCommand { + git rev-parse "$Tag^{}" + } + + $mainCommitResult = Invoke-CapturedCommand { + git rev-parse origin/main + } + + if ( + $tagCommitResult.ExitCode -eq 0 -and + $mainCommitResult.ExitCode -eq 0 + ) { + $tagCommit = ( + $tagCommitResult.Output | + Select-Object -First 1 + ).ToString().Trim() + + $mainCommit = ( + $mainCommitResult.Output | + Select-Object -First 1 + ).ToString().Trim() + + $ancestorResult = Invoke-CapturedCommand { + git merge-base --is-ancestor $tagCommit $mainCommit + } + + if ($ancestorResult.ExitCode -eq 0) { + $relationship = if ($tagCommit -eq $mainCommit) { + 'The release tag points to the current origin/main commit.' + } + else { + 'The release tag is an ancestor of origin/main, which is valid after later maintenance commits.' + } + + Add-CheckResult ` + -Category 'Remote' ` + -Check "$Tag belongs to the origin/main history" ` + -Status 'PASS' ` + -Details "$relationship Tag=$tagCommit; main=$mainCommit." + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "$Tag belongs to the origin/main history" ` + -Status 'FAIL' ` + -Details "The tag commit is not an ancestor of origin/main. Tag=$tagCommit; main=$mainCommit." + } + } + else { + Add-CheckResult ` + -Category 'Remote' ` + -Check "$Tag points to current origin/main" ` + -Status 'FAIL' ` + -Details 'Unable to resolve tag or origin/main.' + } + + $runsResult = Invoke-CapturedCommand { + gh run list ` + --repo $Repository ` + --workflow release.yml ` + --event push ` + --limit 20 ` + --json databaseId,status,conclusion,headBranch,headSha,url + } + + if ($runsResult.ExitCode -eq 0) { + try { + $runs = ( + $runsResult.Output -join [Environment]::NewLine + ) | ConvertFrom-Json + + $tagRun = $runs | + Where-Object { $_.headBranch -eq $Tag } | + Select-Object -First 1 + + if ($null -eq $tagRun) { + Add-CheckResult ` + -Category 'Actions' ` + -Check "Release workflow run found for $Tag" ` + -Status 'FAIL' + } + elseif ($tagRun.status -eq 'completed' -and + $tagRun.conclusion -eq 'success') { + Add-CheckResult ` + -Category 'Actions' ` + -Check "Release workflow succeeded for $Tag" ` + -Status 'PASS' ` + -Details $tagRun.url + } + else { + Add-CheckResult ` + -Category 'Actions' ` + -Check "Release workflow succeeded for $Tag" ` + -Status 'FAIL' ` + -Details "Status=$($tagRun.status); conclusion=$($tagRun.conclusion); $($tagRun.url)" + } + } + catch { + Add-CheckResult ` + -Category 'Actions' ` + -Check "Release workflow succeeded for $Tag" ` + -Status 'FAIL' ` + -Details $_.Exception.Message + } + } + else { + Add-CheckResult ` + -Category 'Actions' ` + -Check "Release workflow succeeded for $Tag" ` + -Status 'FAIL' ` + -Details ($runsResult.Output -join ' ') + } + + $downloadDirectory = Join-Path ` + (Join-Path $RepoPath 'artifacts') ` + "github-release-$Tag-check" + + Remove-Item $downloadDirectory ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + + New-Item ` + -ItemType Directory ` + -Path $downloadDirectory ` + -Force | + Out-Null + + $downloadResult = Invoke-CapturedCommand { + gh release download $Tag ` + --repo $Repository ` + --dir $downloadDirectory ` + --clobber + } + + if ($downloadResult.ExitCode -ne 0) { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Published assets download successfully' ` + -Status 'FAIL' ` + -Details ($downloadResult.Output -join ' ') + + return + } + + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Published assets download successfully' ` + -Status 'PASS' ` + -Details $downloadDirectory + + $downloadedZip = Join-Path ` + $downloadDirectory ` + "WinChess-$Version-win-x64.zip" + + $downloadedChecksum = "$downloadedZip.sha256" + + Test-ZipChecksum ` + -ZipPath $downloadedZip ` + -ChecksumPath $downloadedChecksum + + $extractDirectory = Join-Path $downloadDirectory 'extracted' + + Remove-Item $extractDirectory ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + + if (Test-Path -LiteralPath $downloadedZip -PathType Leaf) { + Expand-Archive ` + -LiteralPath $downloadedZip ` + -DestinationPath $extractDirectory ` + -Force + + $requiredExtractedFiles = @( + 'WinChess.exe', + 'WinChess.dll', + 'ChessDLL.dll', + 'WinChessUCI.exe', + 'LICENSE', + 'VERSION.txt', + 'SHA256SUMS.txt' + ) + + foreach ($fileName in $requiredExtractedFiles) { + $filePath = Join-Path $extractDirectory $fileName + + if (Test-Path -LiteralPath $filePath -PathType Leaf) { + Add-CheckResult ` + -Category 'Remote package' ` + -Check "Downloaded package contains $fileName" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Remote package' ` + -Check "Downloaded package contains $fileName" ` + -Status 'FAIL' + } + } + + Test-PackageChecksums -StagePath $extractDirectory + Test-PackagedUci ` + -UciPath (Join-Path $extractDirectory 'WinChessUCI.exe') + } + + if (-not $KeepDownloadedRelease) { + Remove-Item $downloadDirectory ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + } +} + +function Show-Summary { + Write-Host "`n=== Verification summary ===" -ForegroundColor Cyan + + $Results | + Format-Table ` + Category, Status, Check, Details ` + -AutoSize ` + -Wrap + + Write-Host "" + Write-Host "Passed : $PassCount" -ForegroundColor Green + Write-Host "Warnings: $WarningCount" -ForegroundColor Yellow + Write-Host "Failed : $FailureCount" -ForegroundColor Red + + $reportDirectory = Join-Path $RepoPath 'artifacts' + New-Item ` + -ItemType Directory ` + -Path $reportDirectory ` + -Force | + Out-Null + + $reportPath = Join-Path ` + $reportDirectory ` + "release-readiness-$Version.txt" + + $reportLines = New-Object System.Collections.Generic.List[string] + $reportLines.Add("WinChess release verification") + $reportLines.Add("Version: $Version") + $reportLines.Add("Tag: $Tag") + $reportLines.Add("Repository: $Repository") + $reportLines.Add("Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + $reportLines.Add("") + $reportLines.Add("Passed: $PassCount") + $reportLines.Add("Warnings: $WarningCount") + $reportLines.Add("Failed: $FailureCount") + $reportLines.Add("") + + foreach ($result in $Results) { + $line = "[$($result.Status)] [$($result.Category)] " + + "$($result.Check) -- $($result.Details)" + + $reportLines.Add($line) + } + + $reportLines | + Set-Content ` + -LiteralPath $reportPath ` + -Encoding utf8 + + Write-Host "Report: $reportPath" -ForegroundColor Cyan + + if ($FailureCount -gt 0) { + Write-Host "`nWinChess is NOT ready for this release stage." ` + -ForegroundColor Red + + exit 1 + } + + Write-Host "`nAll mandatory checks passed." ` + -ForegroundColor Green + + exit 0 +} + +try { + Write-Host "WinChess release verification" -ForegroundColor Cyan + Write-Host "Repository : $Repository" + Write-Host "Local path : $RepoPath" + Write-Host "Version : $Version" + Write-Host "Requested mode: $Mode" + + foreach ($commandName in @('git', 'gh')) { + if (Test-ExternalCommand -Name $commandName) { + Add-CheckResult ` + -Category 'Environment' ` + -Check "$commandName command is available" ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Environment' ` + -Check "$commandName command is available" ` + -Status 'FAIL' + } + } + + if ($FailureCount -gt 0) { + Show-Summary + } + + $ghAuthResult = Invoke-CapturedCommand { + gh auth status + } + + if ($ghAuthResult.ExitCode -eq 0) { + Add-CheckResult ` + -Category 'Environment' ` + -Check 'GitHub CLI authentication is valid' ` + -Status 'PASS' + } + else { + Add-CheckResult ` + -Category 'Environment' ` + -Check 'GitHub CLI authentication is valid' ` + -Status 'FAIL' ` + -Details ($ghAuthResult.Output -join ' ') + } + + if (-not (Test-LocalRepository)) { + Show-Summary + } + + Test-GitState + Test-ReleaseConfiguration + Test-LocalPackage + + $remoteState = Get-RemoteReleaseState + $effectiveMode = Resolve-EffectiveMode -RemoteState $remoteState + + Write-Host "`nEffective mode: $effectiveMode" -ForegroundColor Cyan + + if ($effectiveMode -eq 'Inconsistent') { + Add-CheckResult ` + -Category 'Remote' ` + -Check 'Remote tag and release state is consistent' ` + -Status 'FAIL' ` + -Details "TagExists=$($remoteState.TagExists); ReleaseExists=$($remoteState.ReleaseExists). Remove the obsolete tag/release or complete publication." + + Show-Summary + } + + if ($effectiveMode -eq 'PreRelease') { + Test-PreReleaseRemoteState -RemoteState $remoteState + } + elseif ($effectiveMode -eq 'PostRelease') { + Test-PostReleaseRemoteState -RemoteState $remoteState + } + + Show-Summary +} +catch { + Add-CheckResult ` + -Category 'Unexpected' ` + -Check 'Verification completed without an unexpected exception' ` + -Status 'FAIL' ` + -Details $_.Exception.Message + + Show-Summary +}