<# .SYNOPSIS OmnisRouter release gate (T068). Implements FR-018 / SC-009: "no release is tagged unless its published benchmark run passes the gate alongside a clean build and green tests." .DESCRIPTION Blocks a release tag unless ALL of the following pass, in order: 1. Build dotnet build OmnisRouter.slnx -c Release Requires 0 Error(s) AND 0 Warning(s). Directory.Build.props sets true, so a genuine compiler warning already fails the build with a non-zero exit code — but NuGet/restore warnings (e.g. NU1901 advisories) are NOT compiler errors and can leave the build "succeeded" with a non-zero Warning(s) count, so we parse the dotnet build summary explicitly rather than trusting the exit code alone. 2. Tests dotnet test OmnisRouter.slnx -c Release Requires every test project to pass (non-zero exit code on any failure). 3. Benchmark Invokes OmnisBench (github.com/Fortitude-Group/OmnisBench), the companion Python benchmark program that produces the routing policy inputs AND serves as this release gate (see specs/001-omnisrouter/spec.md Dependencies, and routing/BUILD.md). OmnisBench lives in a SEPARATE repo, so this step shells out to a caller-supplied command via -OmnisBenchCommand rather than hard-coding a path to a sibling checkout. A typical real invocation looks like: omnisbench verify --run runs/ which re-grades a published run bundle (results.json + report.html) offline, with zero live API calls (see the OmnisBench CLI: `run` produces a bundle, `verify` re-grades it from stored responses). FR-018 requires that EACH release PUBLISH the OmnisBench frontier (the run bundle) it gates against — that publishing step is a release-process responsibility, not something this script can do for a repo it doesn't own; the script only *gates* on it. -OmnisBenchCommand defaults to an intentionally-unwired PLACEHOLDER that prints a loud warning and FAILS the gate (never silently passes) so nobody can tag a release believing the benchmark ran when it did not. Wire a real release by passing the actual command, e.g.: -OmnisBenchCommand "omnisbench verify --run runs/2026-08-19" On full success, prints (but does NOT run) the `git tag` command for -Tag, per T068 — this script never creates the tag itself; that remains a deliberate, separate human action. .PARAMETER Tag The version to release, e.g. "v0.4.0". Only used to print the suggested `git tag` command once all gates pass. Optional — omit to just run the gate (e.g. in CI) without a tag prompt. .PARAMETER OmnisBenchCommand Shell command that invokes OmnisBench's own verification/benchmark check for this release and returns a non-zero exit code on failure. See DESCRIPTION for the default placeholder behavior and a realistic example. .PARAMETER Solution Path to the solution file. Defaults to OmnisRouter.slnx at the repo root (resolved relative to this script's own location, so it works regardless of the caller's working directory). .PARAMETER Configuration Build/test configuration. Defaults to "Release" (release gates should never run against Debug). .PARAMETER Help Show this help and exit without doing anything. .EXAMPLE pwsh -NoProfile -File scripts/release-gate.ps1 -Help .EXAMPLE # CI usage: run the gate, benchmark step deliberately left unwired (will FAIL, on purpose). pwsh -NoProfile -File scripts/release-gate.ps1 .EXAMPLE # Real release: benchmark step wired to a published OmnisBench run bundle. pwsh -NoProfile -File scripts/release-gate.ps1 ` -Tag v0.4.0 ` -OmnisBenchCommand "omnisbench verify --run runs/2026-08-19" #> [CmdletBinding()] param( [string]$Tag, [string]$OmnisBenchCommand = ( 'Write-Warning "[OmnisBench PLACEHOLDER] -OmnisBenchCommand was not supplied — the ' + 'benchmark gate is NOT wired to a real OmnisBench run and cannot pass. This release gate ' + 'treats an unwired benchmark step as a FAILURE, never a silent pass. Wire it with e.g.: ' + '-OmnisBenchCommand ''omnisbench verify --run runs/'' (see routing/BUILD.md ' + 'and the OmnisBench repo). Remember FR-018: each release must PUBLISH the OmnisBench ' + 'frontier it gates against, not just pass it locally."; ' + 'throw "OmnisBench benchmark gate not wired (see warning above)"' ), [string]$Solution = (Join-Path (Split-Path -Parent $PSScriptRoot) 'OmnisRouter.slnx'), [string]$Configuration = 'Release', [switch]$Help ) $ErrorActionPreference = 'Stop' function Show-Usage { Write-Host @' OmnisRouter release gate (scripts/release-gate.ps1) Blocks a release tag unless build + tests + OmnisBench benchmark all pass (FR-018 / SC-009). USAGE pwsh -NoProfile -File scripts/release-gate.ps1 [-Tag ] [-OmnisBenchCommand ] [-Solution ] [-Configuration ] [-Help] PARAMETERS -Tag Version to print the git tag command for on success, e.g. v0.4.0. Never creates the tag itself. -OmnisBenchCommand Command that runs OmnisBench's release verification and returns a non-zero exit code on failure. Defaults to an unwired placeholder that WARNS and FAILS the gate — it never silently passes. Example: -OmnisBenchCommand "omnisbench verify --run runs/2026-08-19" -Solution Path to OmnisRouter.slnx. Defaults to the repo root. -Configuration Build/test configuration. Defaults to "Release". -Help Show this help and exit. EXAMPLES pwsh -NoProfile -File scripts/release-gate.ps1 -Help pwsh -NoProfile -File scripts/release-gate.ps1 pwsh -NoProfile -File scripts/release-gate.ps1 -Tag v0.4.0 ` -OmnisBenchCommand "omnisbench verify --run runs/2026-08-19" '@ } if ($Help) { Show-Usage exit 0 } # --------------------------------------------------------------------------------------------- # Small helpers # --------------------------------------------------------------------------------------------- # Tracks one gate's outcome for the final PASS/FAIL summary table. $script:GateResults = New-Object System.Collections.Generic.List[pscustomobject] function Add-GateResult { param([string]$Name, [bool]$Passed, [string]$Detail) $script:GateResults.Add([pscustomobject]@{ Name = $Name; Passed = $Passed; Detail = $Detail }) } function Write-Section { param([string]$Title) Write-Host '' Write-Host "=== $Title ===" -ForegroundColor Cyan } # --------------------------------------------------------------------------------------------- # Gate 1: Build (Release, 0 errors, 0 warnings) # --------------------------------------------------------------------------------------------- Write-Section "Gate 1/3: dotnet build $Solution -c $Configuration" $buildOutput = & dotnet build $Solution -c $Configuration 2>&1 | Tee-Object -Variable buildOutputRaw $buildExitCode = $LASTEXITCODE $buildText = ($buildOutput | Out-String) # dotnet's final summary lines look like: # 0 Warning(s) # 0 Error(s) # Take the LAST match of each (the aggregate solution-wide total), not per-project subtotals. $warningMatches = [regex]::Matches($buildText, '(?m)^\s*(\d+)\s+Warning\(s\)\s*$') $errorMatches = [regex]::Matches($buildText, '(?m)^\s*(\d+)\s+Error\(s\)\s*$') $warningCount = if ($warningMatches.Count -gt 0) { [int]$warningMatches[$warningMatches.Count - 1].Groups[1].Value } else { -1 } $errorCount = if ($errorMatches.Count -gt 0) { [int]$errorMatches[$errorMatches.Count - 1].Groups[1].Value } else { -1 } $buildPassed = ($buildExitCode -eq 0) -and ($errorCount -eq 0) -and ($warningCount -eq 0) if ($buildPassed) { Write-Host "PASS build succeeded: 0 Warning(s), 0 Error(s)" -ForegroundColor Green Add-GateResult -Name 'Build (Release)' -Passed $true -Detail '0 warnings, 0 errors' } else { Write-Host $buildText $detail = "exit=$buildExitCode warnings=$warningCount errors=$errorCount" Write-Host "FAIL build gate failed ($detail)" -ForegroundColor Red Add-GateResult -Name 'Build (Release)' -Passed $false -Detail $detail } # --------------------------------------------------------------------------------------------- # Gate 2: Tests (all green) # --------------------------------------------------------------------------------------------- Write-Section "Gate 2/3: dotnet test $Solution -c $Configuration" $testOutput = & dotnet test $Solution -c $Configuration 2>&1 | Tee-Object -Variable testOutputRaw $testExitCode = $LASTEXITCODE $testText = ($testOutput | Out-String) $testPassed = ($testExitCode -eq 0) if ($testPassed) { Write-Host "PASS all tests green" -ForegroundColor Green Add-GateResult -Name 'Tests (Release)' -Passed $true -Detail 'exit=0' } else { Write-Host $testText Write-Host "FAIL one or more tests failed (exit=$testExitCode)" -ForegroundColor Red Add-GateResult -Name 'Tests (Release)' -Passed $false -Detail "exit=$testExitCode" } # --------------------------------------------------------------------------------------------- # Gate 3: OmnisBench benchmark # --------------------------------------------------------------------------------------------- Write-Section "Gate 3/3: OmnisBench benchmark" Write-Host "Command: $OmnisBenchCommand" $LASTEXITCODE = $null $benchPassed = $false $benchDetail = '' try { Invoke-Expression $OmnisBenchCommand # Native commands set $LASTEXITCODE; pure-PowerShell commands leave it untouched. Treat # "never set, no exception thrown" as success (e.g. a caller-supplied PS function that # returns normally), and "explicitly non-zero" as failure — but never treat a stale exit # code from an EARLIER dotnet call as this gate's result, since we reset it to $null above. if ($null -eq $LASTEXITCODE -or $LASTEXITCODE -eq 0) { $benchPassed = $true $benchDetail = 'command completed successfully' } else { $benchDetail = "command exited with code $LASTEXITCODE" } } catch { $benchDetail = $_.Exception.Message } if ($benchPassed) { Write-Host "PASS OmnisBench gate: $benchDetail" -ForegroundColor Green Add-GateResult -Name 'OmnisBench benchmark' -Passed $true -Detail $benchDetail } else { Write-Host "FAIL OmnisBench gate: $benchDetail" -ForegroundColor Red Add-GateResult -Name 'OmnisBench benchmark' -Passed $false -Detail $benchDetail } # --------------------------------------------------------------------------------------------- # Gate: winget manifest (best-effort — validates when winget is present, else deferred to CI) # --------------------------------------------------------------------------------------------- Write-Section 'Gate: winget manifest' $wingetManifests = Join-Path $PSScriptRoot '..' 'installer' 'winget' 'manifests' if (Get-Command winget -ErrorAction SilentlyContinue) { winget validate --manifest $wingetManifests if ($LASTEXITCODE -eq 0) { Add-GateResult -Name 'winget manifest' -Passed $true -Detail 'winget validate passed' } else { Add-GateResult -Name 'winget manifest' -Passed $false -Detail "winget validate exited $LASTEXITCODE" } } else { # Not blocking on a machine without the winget CLI; the release job validates it there. Add-GateResult -Name 'winget manifest' -Passed $true -Detail 'winget CLI absent; validated in CI' Write-Host ' winget CLI not present; skipping local validation (the release job validates it).' } # --------------------------------------------------------------------------------------------- # Summary + tag command # --------------------------------------------------------------------------------------------- Write-Section 'Release gate summary' $allPassed = $true foreach ($result in $script:GateResults) { $status = if ($result.Passed) { 'PASS' } else { 'FAIL' } $color = if ($result.Passed) { 'Green' } else { 'Red' } Write-Host (" [{0}] {1,-24} {2}" -f $status, $result.Name, $result.Detail) -ForegroundColor $color if (-not $result.Passed) { $allPassed = $false } } Write-Host '' if ($allPassed) { Write-Host 'RELEASE GATE: PASS — all checks green.' -ForegroundColor Green if ($Tag) { Write-Host '' Write-Host 'Gate passed. This script does NOT create the tag — run it yourself:' Write-Host " git tag -a $Tag -m `"Release $Tag`"" -ForegroundColor Yellow Write-Host " git push origin $Tag" -ForegroundColor Yellow Write-Host '' Write-Host 'Remember (FR-018): publish the OmnisBench frontier (run bundle) that backed' -ForegroundColor Yellow Write-Host "this gate's benchmark step alongside the $Tag release." -ForegroundColor Yellow } else { Write-Host '' Write-Host 'No -Tag supplied — pass -Tag to see the suggested git tag command.' } exit 0 } else { Write-Host 'RELEASE GATE: FAIL — do not tag a release.' -ForegroundColor Red exit 1 }