param( [string]$OutputRoot = 'build\Release\publish', [switch]$SkipRuntimeCheck ) $ErrorActionPreference = 'Stop' $repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $outputRootPath = [IO.Path]::GetFullPath((Join-Path $repository $OutputRoot)) $buildRootPath = [IO.Path]::GetFullPath((Join-Path $repository 'build')) if (-not $outputRootPath.StartsWith($buildRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Release output must remain inside the repository build directory.' } $appProject = Join-Path $repository 'src\Agent.App\Agent.App.csproj' $adminProject = Join-Path $repository 'src\Agent.Admin\Agent.Admin.Host\Agent.Admin.Host.csproj' function Get-ApplicationVersion([string]$project) { $value = (& dotnet msbuild $project -nologo -getProperty:Version | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($value)) { throw "Could not resolve the Application Version for $project." } return $value } $version = Get-ApplicationVersion $appProject $adminVersion = Get-ApplicationVersion $adminProject if ($version -ne $adminVersion) { throw "Main/admin Application Version mismatch: $version / $adminVersion" } if ($version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') { throw "Application Version is not SemVer: $version" } $releaseVersion = [Version]($version -replace '[-+].*$', '') $bundleName = 'winagent-sandbox-mcp' $assetBase = "$bundleName-v$version-win-x64" $bundle = Join-Path $outputRootPath $bundleName $zip = Join-Path $outputRootPath "$assetBase.zip" $checksum = "$zip.sha256" $stagingRoot = Join-Path $outputRootPath ('.staging-' + [Guid]::NewGuid().ToString('N')) $stagingBundle = Join-Path $stagingRoot $bundleName $forbiddenNames = @('agent.local.yaml', 'local', 'logs', 'state', 'applied-configurations', 'TestResults', 'artifacts', '.staging') function Assert-ReleaseIdentity([string]$path, [string]$assemblyPath, [string]$label) { $info = [Diagnostics.FileVersionInfo]::GetVersionInfo($path) $fileVersion = [Version]$info.FileVersion if ($fileVersion.Major -ne $releaseVersion.Major -or $fileVersion.Minor -ne $releaseVersion.Minor -or $fileVersion.Build -ne $releaseVersion.Build) { throw "$label FileVersion '$($info.FileVersion)' does not match Application Version '$version'." } $assemblyVersion = [Reflection.AssemblyName]::GetAssemblyName($assemblyPath).Version if ($assemblyVersion.Major -ne $releaseVersion.Major -or $assemblyVersion.Minor -ne $releaseVersion.Minor -or $assemblyVersion.Build -ne $releaseVersion.Build) { throw "$label AssemblyVersion '$assemblyVersion' does not match Application Version '$version'." } if ($info.ProductVersion -notmatch ('^' + [Regex]::Escape($version) + '(?:\+[0-9A-Za-z.-]+)?$')) { throw "$label InformationalVersion '$($info.ProductVersion)' is not compatible with '$version'." } [PSCustomObject]@{ Label = $label ApplicationVersion = $version AssemblyVersion = $assemblyVersion.ToString() FileVersion = $info.FileVersion InformationalVersion = $info.ProductVersion } } function Assert-NoUserState([string]$root) { $forbidden = Get-ChildItem -LiteralPath $root -Force -Recurse | Where-Object Name -in $forbiddenNames | Select-Object -First 1 if ($forbidden) { $relative = [IO.Path]::GetRelativePath($root, $forbidden.FullName) throw "Release artifact must not contain user state, local assets, or test artifacts: $relative" } } New-Item -ItemType Directory -Force -Path $outputRootPath | Out-Null if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force } if (Test-Path -LiteralPath $checksum) { Remove-Item -LiteralPath $checksum -Force } New-Item -ItemType Directory -Path $stagingBundle | Out-Null try { dotnet restore $appProject -r win-x64 --locked-mode if ($LASTEXITCODE -ne 0) { throw "win-x64 locked restore failed with exit code $LASTEXITCODE" } dotnet restore $adminProject -r win-x64 --locked-mode if ($LASTEXITCODE -ne 0) { throw "win-x64 admin locked restore failed with exit code $LASTEXITCODE" } dotnet publish $appProject -c Release -r win-x64 --self-contained true --no-restore --output $stagingBundle if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" } $adminBundle = Join-Path $stagingBundle 'admin' dotnet publish $adminProject -c Release -r win-x64 --self-contained true --no-restore --output $adminBundle if ($LASTEXITCODE -ne 0) { throw "admin dotnet publish failed with exit code $LASTEXITCODE" } $required = @( 'agent-mcp.exe', 'admin\agent-mcp-admin.exe', 'agent.yaml', 'config\locales\en.json', 'config\locales\ja.json', 'prompts\user-system.md', 'skills', 'README.md', 'CHANGELOG.md' ) foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $stagingBundle $relative))) { throw "Release Bundle is missing $relative" } } Assert-NoUserState $stagingBundle $mainVersionInfo = Assert-ReleaseIdentity (Join-Path $stagingBundle 'agent-mcp.exe') (Join-Path $stagingBundle 'agent-mcp.dll') 'main' $adminVersionInfo = Assert-ReleaseIdentity (Join-Path $stagingBundle 'admin\agent-mcp-admin.exe') (Join-Path $stagingBundle 'admin\agent-mcp-admin.dll') 'admin' $manifestPath = Join-Path $stagingBundle 'publish-manifest.sha256' Get-ChildItem -LiteralPath $stagingBundle -File -Recurse | Where-Object FullName -ne $manifestPath | Sort-Object FullName | ForEach-Object { $relative = [IO.Path]::GetRelativePath($stagingBundle, $_.FullName).Replace('\', '/') $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() "$hash $relative" } | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM Compress-Archive -LiteralPath $stagingBundle -DestinationPath $zip -CompressionLevel Optimal $zipHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() "$zipHash $([IO.Path]::GetFileName($zip))" | Set-Content -LiteralPath $checksum -Encoding ascii $freshRoot = Join-Path ([IO.Path]::GetTempPath()) ('winagent-sandbox-mcp-release-check-' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $freshRoot | Out-Null try { Expand-Archive -LiteralPath $zip -DestinationPath $freshRoot $freshItems = @(Get-ChildItem -LiteralPath $freshRoot -Force) if ($freshItems.Count -ne 1 -or -not $freshItems[0].PSIsContainer -or $freshItems[0].Name -ne $bundleName) { throw "ZIP must contain exactly one unversioned root directory named '$bundleName'." } $freshBundle = Join-Path $freshRoot $bundleName Assert-NoUserState $freshBundle $sourceReferences = Get-ChildItem -LiteralPath $freshBundle -File -Recurse | Where-Object Extension -in @('.yaml', '.json', '.md', '.config') | Select-String -SimpleMatch -Pattern $repository if ($sourceReferences) { throw 'Release Bundle contains a source-tree reference.' } foreach ($line in Get-Content -LiteralPath (Join-Path $freshBundle 'publish-manifest.sha256')) { if ($line -notmatch '^([0-9a-f]{64}) (.+)$') { throw "Invalid manifest entry: $line" } $actual = (Get-FileHash -LiteralPath (Join-Path $freshBundle $Matches[2]) -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $Matches[1]) { throw "Manifest hash mismatch: $($Matches[2])" } } if (-not $SkipRuntimeCheck) { Push-Location $freshBundle try { & .\agent-mcp.exe show .\agent.yaml if ($LASTEXITCODE -ne 0) { throw "Bundle configuration check failed with exit code $LASTEXITCODE." } } finally { Pop-Location } } } finally { if (Test-Path -LiteralPath $freshRoot) { Remove-Item -LiteralPath $freshRoot -Recurse -Force } } $bundleFullPath = [IO.Path]::GetFullPath($bundle) if (-not $bundleFullPath.StartsWith($outputRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($bundleFullPath) -ne $bundleName) { throw 'Fixed Release Bundle target escaped the validated output root.' } if (Test-Path -LiteralPath $bundleFullPath) { Remove-Item -LiteralPath $bundleFullPath -Recurse -Force } Move-Item -LiteralPath $stagingBundle -Destination $bundleFullPath Assert-NoUserState $bundleFullPath $mainVersionInfo $adminVersionInfo Write-Output "Bundle: $bundle" Write-Output "ZIP: $zip" Write-Output "SHA-256: $zipHash" } finally { if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force } }