diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03a7b2a..d6c21cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,8 @@ jobs: run: pwsh -NoProfile -File eng/Publish.ps1 -Configuration Debug - name: Publish Release distribution run: pwsh -NoProfile -File eng/Publish.ps1 -Configuration Release + - name: Package release assets + run: pwsh -NoProfile -File eng/Release.ps1 -DistributionPath artifacts/dist/release - name: Upload Debug distribution uses: actions/upload-artifact@v7.0.1 with: @@ -50,3 +52,9 @@ jobs: name: CheatEngine.Mcp-release path: artifacts/dist/release/** if-no-files-found: error + - name: Upload release assets + uses: actions/upload-artifact@v7.0.1 + with: + name: CheatEngine.Mcp-release-assets + path: artifacts/releases/** + if-no-files-found: error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c1c719..1aeb5f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,34 @@ Maintainers name the checks a change must pass with these gates: A pull request needs V+ at least, and VN when it touches Lua. +## Releases + +[`eng/Release.ps1`](eng/Release.ps1) is the maintained release entry point. +Every release contains exactly two uploaded assets: `CheatEngine.Mcp--win-x64.zip` and `SHA256SUMS.txt`. +The ZIP contains the complete `CheatEngine.Mcp/` plugin folder, including `CheatEngine.Mcp.Plugin.dll` and its dependencies, the gateway executable, installation instructions and licenses. +Operator guidance is served as MCP resources and prompts; there is no separate skill ZIP. + +1. Set `Version` in `Directory.Build.props`, merge the change into `main`, and pass the required checks. +2. From the clean tested checkout, build and package with `pwsh -NoProfile -File eng/Release.ps1`. + This invokes `eng/Publish.ps1`, including its native gateway smoke check, then verifies every ZIP entry against the distribution and generates checksums. +3. Create and push the annotated `v` tag at the binaries' source commit. The script validates that the plugin, product DLLs, gateway and remote tag agree; it never creates or moves tags. +4. Upload a draft, review its notes and files, then publish: + +```powershell +pwsh -NoProfile -File eng/Release.ps1 -DistributionPath artifacts/dist/release -Upload +pwsh -NoProfile -File eng/Release.ps1 -DistributionPath artifacts/dist/release -Upload -Publish +``` + +Use `-NotesFile ` for reviewed release notes; existing notes are preserved when it is omitted. +Prerelease status follows the version suffix. Upload alone leaves new releases as drafts and preserves an existing release's published status. +`-WhatIf` performs no build, writes or GitHub changes. + +Assets are staged under `artifacts/releases//`; `-OutputDirectory` selects another output folder. +The script reuses a valid existing ZIP, writes newly packaged ZIPs with stable ordering and timestamps, verifies uploaded SHA-256 digests and skips matching assets on repeat runs. +Changed binary assets are refused: issue a new version instead of replacing a published payload. +The checksum file can be updated, and known old standalone DLL, EXE, plugin ZIP and skill ZIP assets are removed only after the standard downloads have been verified on GitHub. +Unrecognized output files or release assets stop the operation for review. + ## Packages and lock files - Package versions are managed centrally in [`Directory.Packages.props`](Directory.Packages.props), with transitive pinning; a `PackageReference` never carries a `Version`. diff --git a/README.md b/README.md index badf294..e47b6a3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Run the gateway and Cheat Engine under the same Windows user account. ### 1. Get the deployment files Download and extract `CheatEngine.Mcp-2.0.0-beta.2-win-x64.zip` from the [beta release](https://github.com/CheatEngineNet/CheatEngine.Mcp/releases/tag/v2.0.0-beta.2). -The ZIP includes the complete plugin folder, gateway executable, installation instructions, and licenses. +The ZIP includes **`CheatEngine.Mcp/CheatEngine.Mcp.Plugin.dll`**, every plugin dependency, the gateway executable, installation instructions, and licenses. +Each release has one complete Windows x64 ZIP and `SHA256SUMS.txt`; separate plugin, skill, DLL, or EXE downloads are unnecessary. When upgrading from beta.1, disable the plugin, close Cheat Engine and the gateway, and remove the old single-DLL plugin entry. Replace the complete plugin folder and gateway executable with the matching files from this release; do not mix versions. Then add `CheatEngine.Mcp.Plugin.dll` from the new folder as described below. To build the same layout from source instead, run this from the repository root with .NET SDK **10.0.401**, PowerShell 7, and the Windows C++ build tools required by Native AOT: @@ -270,6 +271,16 @@ Project dependencies are centrally versioned in [`Directory.Packages.props`](Dir The plugin is framework-dependent inside Cheat Engine; the gateway is published as a self-contained Windows x64 Native AOT executable. The [contributor guide](CONTRIBUTING.md#build-and-test) covers CI checks, formatting, contract snapshots, and packaging. +To build and package the standard release downloads, use: + +```powershell +pwsh -NoProfile -File eng/Release.ps1 +``` + +This runs the publish pipeline and writes one verified ZIP plus `SHA256SUMS.txt` under `artifacts/releases//`. +To package an already published distribution without rebuilding it, pass `-DistributionPath artifacts/dist/release`. +GitHub upload is explicit; see [Releases](CONTRIBUTING.md#releases). + ## Verification The portable xUnit v3 suite uses Client doubles and real local HTTP/stdio hosts; it does not require Cheat Engine. diff --git a/eng/Release.ps1 b/eng/Release.ps1 new file mode 100644 index 0000000..9d32352 --- /dev/null +++ b/eng/Release.ps1 @@ -0,0 +1,277 @@ +#requires -Version 7.0 +<# +.SYNOPSIS +Builds and packages one complete Windows x64 ZIP and SHA256SUMS.txt. +.DESCRIPTION +Without -Upload this only prepares local assets. -Upload creates a draft release or repairs an existing release. +-Publish publishes the verified draft. Git tags must already exist and match the binaries' source commit. +Existing binary assets are immutable; a changed binary needs a new version. Only the checksum file is replaced. +.EXAMPLE +pwsh -NoProfile -File eng/Release.ps1 +.EXAMPLE +pwsh -NoProfile -File eng/Release.ps1 -DistributionPath artifacts/dist/release -Upload -Publish +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $DistributionPath, + [string] $OutputDirectory, + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string] $Repository = 'CheatEngineNet/CheatEngine.Mcp', + [string] $NotesFile, + [switch] $Upload, + [switch] $Publish +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-GitHub([string[]] $Arguments) { + $result = & gh @Arguments + if ($LASTEXITCODE -ne 0) { throw "GitHub CLI failed: gh $($Arguments -join ' ')" } + return $result +} + +function Get-Release { + # A draft is not returned by GitHub's get-release-by-tag endpoint. + $pages = (Invoke-GitHub @('api', '--paginate', '--slurp', "repos/$Repository/releases?per_page=100")) | + ConvertFrom-Json + $matches = @($pages | ForEach-Object { $_ } | Where-Object { $_.tag_name -eq $tag }) + if ($matches.Count -gt 1) { throw "Multiple releases use $tag." } + if ($matches.Count -eq 1) { return $matches[0] } + return $null +} + +function Test-Archive([string] $Path) { + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $entries = @($archive.Entries | Where-Object { $_.Name.Length -gt 0 }) + if ($entries.Count -ne $inventory.Count) { throw 'ZIP does not contain the complete distribution.' } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($entry in $entries) { + if (-not $inventory.ContainsKey($entry.FullName) -or -not $seen.Add($entry.FullName)) { + throw "Unexpected or duplicate ZIP entry: $($entry.FullName)" + } + $stream = $entry.Open() + try { $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream)) } + finally { $stream.Dispose() } + if ($hash -ne $inventory[$entry.FullName].Hash) { throw "ZIP hash mismatch: $($entry.FullName)" } + } + } + finally { $archive.Dispose() } +} + +function Assert-UploadedAsset($Asset, [string] $Path) { + $hash = 'sha256:' + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($Asset.state -ne 'uploaded' -or $Asset.size -ne (Get-Item -LiteralPath $Path).Length -or + $Asset.digest -ne $hash) { throw "GitHub asset does not match the local file: $($Asset.name)" } +} + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Push-Location $repoRoot +try { + if (-not $PSCmdlet.ShouldProcess($repoRoot, 'Prepare release assets and perform the requested GitHub operations')) { + return + } + if (-not $DistributionPath) { + if ($Upload -or $Publish) { + $status = @(git status --porcelain) + if ($LASTEXITCODE -ne 0) { throw 'git status failed; cannot confirm a clean checkout.' } + if ($status.Count) { + throw 'Building a GitHub release requires a clean checkout; commit or preserve local changes first.' + } + } + & (Join-Path $PSScriptRoot 'Publish.ps1') -Configuration Release + $DistributionPath = Join-Path $repoRoot 'artifacts/dist/release' + } + $dist = (Resolve-Path -LiteralPath $DistributionPath).Path + $plugin = Join-Path $dist 'CheatEngine.Mcp' + $dll = Join-Path $plugin 'CheatEngine.Mcp.Plugin.dll' + $gateway = Join-Path $dist 'CheatEngine.Mcp.Gateway.exe' + $expectedRoot = @('CheatEngine.Mcp', 'CheatEngine.Mcp.Gateway.exe', 'LICENSE', 'THIRD-PARTY-NOTICES.md') + $unexpected = @(Get-ChildItem -LiteralPath $dist -Force | Where-Object { $_.Name -notin $expectedRoot }) + if ($unexpected.Count) { throw "Unexpected distribution entries: $($unexpected.Name -join ', ')" } + foreach ($path in @($dll, $gateway, (Join-Path $dist 'LICENSE'), (Join-Path $dist 'THIRD-PARTY-NOTICES.md'))) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Incomplete distribution: $path" } + } + $identity = [Diagnostics.FileVersionInfo]::GetVersionInfo($dll).ProductVersion + if ($identity -notmatch '^(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)\+(?[0-9a-f]{40})$') { + throw 'The plugin must carry its release version and full source commit.' + } + $version = $Matches.version + $sourceCommit = $Matches.commit + $tag = "v$version" + if ([Diagnostics.FileVersionInfo]::GetVersionInfo($gateway).ProductVersion -ne $identity) { + throw 'Plugin and gateway were not built from the same version and commit.' + } + $depsPath = Join-Path $plugin 'CheatEngine.Mcp.Plugin.deps.json' + $deps = Get-Content -Raw -LiteralPath $depsPath | ConvertFrom-Json -AsHashtable + $required = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in @('CheatEngine.Mcp.Plugin.dll', 'CheatEngine.Mcp.Plugin.deps.json', + 'CheatEngine.Mcp.Plugin.runtimeconfig.json', 'cheatengine-sdk-lua-bridge.dll', + 'appsettings.json', 'README.md', 'LICENSE', 'THIRD-PARTY-NOTICES.md')) { + [void] $required.Add($name) + } + foreach ($target in $deps.targets.Values) { + foreach ($library in $target.Values) { + foreach ($kind in @('runtime', 'native')) { + if ($library.ContainsKey($kind)) { + foreach ($asset in $library[$kind].Keys) { [void] $required.Add([IO.Path]::GetFileName($asset)) } + } + } + if ($library.ContainsKey('resources')) { + foreach ($asset in $library.resources.GetEnumerator()) { + [void] $required.Add(($asset.Value.locale + '/' + [IO.Path]::GetFileName($asset.Key))) + } + } + if ($library.ContainsKey('runtimeTargets')) { + foreach ($asset in $library.runtimeTargets.Keys) { [void] $required.Add($asset.Replace('\', '/')) } + } + } + } + $inventory = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + $allEntries = @(Get-ChildItem -LiteralPath $dist -Recurse -Force) + if (@($allEntries | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }).Count) { + throw 'The distribution must not contain symbolic links or junctions.' + } + foreach ($file in @($allEntries | Where-Object { -not $_.PSIsContainer })) { + $relative = [IO.Path]::GetRelativePath($dist, $file.FullName).Replace('\', '/') + if ($relative.StartsWith('CheatEngine.Mcp/', [StringComparison]::Ordinal) -and + -not $required.Remove($relative.Substring('CheatEngine.Mcp/'.Length))) { + throw "Unexpected plugin file: $relative" + } + if ($file.Name -like 'CheatEngine.Mcp*.dll' -and + [Diagnostics.FileVersionInfo]::GetVersionInfo($file.FullName).ProductVersion -ne $identity) { + throw "Mixed product versions in the plugin folder: $($file.Name)" + } + $inventory.Add($relative, @{ Path = $file.FullName; Hash = (Get-FileHash -LiteralPath $file.FullName).Hash }) + } + if ($required.Count) { throw "Missing plugin dependencies: $($required -join ', ')" } + if ($Upload -or $Publish) { + $ref = (Invoke-GitHub @('api', "repos/$Repository/git/ref/tags/$tag")) | ConvertFrom-Json + for ($depth = 0; $ref.object.type -eq 'tag' -and $depth -lt 4; $depth++) { + $ref = (Invoke-GitHub @('api', "repos/$Repository/git/tags/$($ref.object.sha)")) | ConvertFrom-Json + } + if ($ref.object.type -ne 'commit' -or $ref.object.sha -ne $sourceCommit) { + throw 'The remote tag does not point to the binaries source commit; tags are never created or moved here.' + } + } + if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repoRoot "artifacts/releases/$version" } + $output = [IO.Path]::GetFullPath($OutputDirectory) + $distPrefix = $dist.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if ($output -eq $dist -or $output.StartsWith($distPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Release output must be outside the distribution.' + } + [IO.Directory]::CreateDirectory($output) | Out-Null + if ((Get-Item -LiteralPath $output).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Release output must not be a symbolic link or junction.' + } + $zipName = "CheatEngine.Mcp-$version-win-x64.zip" + $zip = Join-Path $output $zipName + $checksums = Join-Path $output 'SHA256SUMS.txt' + $obsolete = @("CheatEngine.Mcp-plugin-$version-win-x64.zip", "CheatEngine.Mcp-skill-$version.zip", + 'CheatEngine.Mcp.Gateway.exe', 'CheatEngine.Mcp.Plugin.dll', 'CheatEngine.Mcp.dll') + $unexpected = @(Get-ChildItem -LiteralPath $output -Force | Where-Object { + $_.PSIsContainer -or ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $_.Name -notin (@($zipName, 'SHA256SUMS.txt') + $obsolete) + }) + if ($unexpected.Count) { throw "Unexpected release-output entries: $($unexpected.Name -join ', ')" } + $reuse = $false + if (Test-Path -LiteralPath $zip) { + try { Test-Archive $zip; $reuse = $true } + catch { Write-Host 'Existing ZIP differs; preparing a verified replacement.' } + } + if (-not $reuse) { + $temporary = Join-Path $output "$zipName.$([guid]::NewGuid().ToString('N')).tmp" + try { + $archive = [IO.Compression.ZipFile]::Open($temporary, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($relative in @($inventory.Keys | Sort-Object -CaseSensitive)) { + $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) + # Stable ZIP metadata: rebuilding the same contents does not change container hashes. + $entry.LastWriteTime = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + $source = [IO.File]::OpenRead($inventory[$relative].Path) + $destination = $entry.Open() + try { $source.CopyTo($destination) } + finally { $source.Dispose(); $destination.Dispose() } + } + } + finally { $archive.Dispose() } + Test-Archive $temporary + [IO.File]::Move($temporary, $zip, $true) + } + finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } } + } + $hash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText($checksums, "$hash $zipName`n", [Text.UTF8Encoding]::new($false)) + foreach ($name in $obsolete) { + $path = Join-Path $output $name + if (Test-Path -LiteralPath $path -PathType Leaf) { Remove-Item -LiteralPath $path -Force } + } + Write-Host "Verified $($inventory.Count) files, including CheatEngine.Mcp/CheatEngine.Mcp.Plugin.dll." + Get-ChildItem -LiteralPath $output | Select-Object Name, Length + if (-not ($Upload -or $Publish)) { return } + + $release = Get-Release + if ($null -eq $release) { + $arguments = @('release', 'create', $tag, '--repo', $Repository, '--verify-tag', '--draft', + '--latest=false', '--title', "CheatEngine.Mcp $version") + if ($version.Contains('-')) { $arguments += '--prerelease' } + if ($NotesFile) { $arguments += @('--notes-file', (Resolve-Path -LiteralPath $NotesFile).Path) } + else { + $notes = "Download $zipName and extract the complete folder. The plugin DLL is " + + 'CheatEngine.Mcp/CheatEngine.Mcp.Plugin.dll; keep its dependencies beside it. ' + + 'Configure your AI client to start CheatEngine.Mcp.Gateway.exe as a stdio MCP server. ' + + "See the included README for installation and runtime requirements. Verify the ZIP using SHA256SUMS.txt.`n`n" + + "Built from $sourceCommit. Operator guidance ships as MCP resources and prompts; no separate skill is required." + $arguments += @('--notes', $notes) + } + Invoke-GitHub $arguments | Out-Host + $release = Get-Release + } + # Refuse changed binaries before any upload or deletion. Existing releases retain their validated payload. + $existing = @($release.assets | Where-Object { $_.name -eq $zipName }) + if ($existing.Count) { Assert-UploadedAsset $existing[0] $zip } + $foreign = @($release.assets | Where-Object { $_.name -notin (@($zipName, 'SHA256SUMS.txt') + $obsolete) }) + if ($foreign.Count) { throw 'Release contains unrecognized assets; preserve them and review the layout manually.' } + foreach ($path in @($zip, $checksums)) { + $name = [IO.Path]::GetFileName($path) + $asset = @($release.assets | Where-Object { $_.name -eq $name }) + $matches = $false + if ($asset.Count) { + try { Assert-UploadedAsset $asset[0] $path; $matches = $true } + catch { if ($name -ne 'SHA256SUMS.txt') { throw } } + } + if (-not $matches) { + $arguments = @('release', 'upload', $tag, $path, '--repo', $Repository) + if ($asset.Count) { $arguments += '--clobber' } # Only a checksum replacement reaches this path. + Invoke-GitHub $arguments | Out-Host + } + } + $release = Get-Release + foreach ($path in @($zip, $checksums)) { + $name = [IO.Path]::GetFileName($path) + $asset = @($release.assets | Where-Object { $_.name -eq $name }) + if ($asset.Count -ne 1) { throw "Missing or duplicate GitHub asset: $name" } + Assert-UploadedAsset $asset[0] $path + } + $zipAsset = @($release.assets | Where-Object { $_.name -eq $zipName })[0] + Invoke-GitHub @('api', '--method', 'PATCH', "repos/$Repository/releases/assets/$($zipAsset.id)", + '-f', 'label=Windows x64: plugin DLL, dependencies, gateway and instructions') | Out-Null + # Delete only the named obsolete assets, after both standard assets have been verified on GitHub. + foreach ($asset in @($release.assets | Where-Object { $_.name -in $obsolete })) { + Invoke-GitHub @('api', '--method', 'DELETE', "repos/$Repository/releases/assets/$($asset.id)") | Out-Null + } + if ($NotesFile -and $null -ne $release) { + Invoke-GitHub @('release', 'edit', $tag, '--repo', $Repository, '--notes-file', + (Resolve-Path -LiteralPath $NotesFile).Path) | Out-Host + } + if ($Publish) { + $prerelease = $version.Contains('-').ToString().ToLowerInvariant() + $latest = (-not $version.Contains('-')).ToString().ToLowerInvariant() + Invoke-GitHub @('release', 'edit', $tag, '--repo', $Repository, '--draft=false', + "--prerelease=$prerelease", "--latest=$latest") | Out-Host + } + $release = Get-Release + if ($release.assets.Count -ne 2) { throw 'Final release does not have exactly the ZIP and checksum assets.' } + Write-Host "Verified release: $($release.html_url) (draft=$($release.draft))" +} +finally { Pop-Location } diff --git a/tests/CheatEngine.Mcp.Tests/Contract/ReleaseScriptTests.cs b/tests/CheatEngine.Mcp.Tests/Contract/ReleaseScriptTests.cs new file mode 100644 index 0000000..5867bcf --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Contract/ReleaseScriptTests.cs @@ -0,0 +1,255 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Security.Cryptography; + +using CheatEngine.Mcp.Tests.Support; + +namespace CheatEngine.Mcp.Tests.Contract; + +/// Executes the release packager against owned fixtures, without building or contacting GitHub. +public sealed class ReleaseScriptTests +{ + [Fact] + public async Task Package_CompleteDistribution_ContainsDllGatewayDependenciesAndMatchingChecksum() + { + await using DistributionFixture fixture = new(); + + RunResult run = await fixture.RunAsync(); + + Assert.Equal(0, run.ExitCode); + Assert.Equal([fixture.ZipName, "SHA256SUMS.txt"], + Directory.GetFiles(fixture.Output).Select(Path.GetFileName).Order(StringComparer.Ordinal)); + using ZipArchive archive = ZipFile.OpenRead(fixture.Zip); + Assert.Contains(archive.Entries, static entry => entry.FullName == "CheatEngine.Mcp/CheatEngine.Mcp.Plugin.dll"); + Assert.Contains(archive.Entries, static entry => entry.FullName == "CheatEngine.Mcp.Gateway.exe"); + Assert.Contains(archive.Entries, static entry => entry.FullName == "CheatEngine.Mcp/Dependency.dll"); + Assert.Equal(Directory.GetFiles(fixture.Distribution, "*", SearchOption.AllDirectories).Length, + archive.Entries.Count); + foreach (ZipArchiveEntry entry in archive.Entries) + { + using Stream content = entry.Open(); + Assert.Equal(FileHash(Path.Combine(fixture.Distribution, entry.FullName)), + Convert.ToHexString(SHA256.HashData(content))); + } + Assert.Equal($"{FileHash(fixture.Zip).ToLowerInvariant()} {fixture.ZipName}\n", + await File.ReadAllTextAsync(Path.Combine(fixture.Output, "SHA256SUMS.txt"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Package_RepeatedAndFreshRuns_KeepIdenticalZipBytesAndRemoveObsoleteAssets() + { + await using DistributionFixture fixture = new(); + Assert.Equal(0, (await fixture.RunAsync()).ExitCode); + string original = FileHash(fixture.Zip); + await File.WriteAllTextAsync(Path.Combine(fixture.Output, "CheatEngine.Mcp.Gateway.exe"), "obsolete", + TestContext.Current.CancellationToken); + + Assert.Equal(0, (await fixture.RunAsync()).ExitCode); + + Assert.Equal(original, FileHash(fixture.Zip)); + Assert.Equal(2, Directory.GetFiles(fixture.Output).Length); + File.Delete(fixture.Zip); + Assert.Equal(0, (await fixture.RunAsync()).ExitCode); + Assert.Equal(original, FileHash(fixture.Zip)); + } + + [Theory] + [InlineData("missing dependency", "Missing plugin dependencies")] + [InlineData("foreign file", "Unexpected plugin file")] + [InlineData("mixed binaries", "same version and commit")] + public async Task Package_InvalidDistribution_RefusesAndPreservesExistingAssets(string condition, string message) + { + await using DistributionFixture fixture = new(); + Assert.Equal(0, (await fixture.RunAsync()).ExitCode); + string original = FileHash(fixture.Zip); + switch (condition) + { + case "missing dependency": + File.Delete(Path.Combine(fixture.Distribution, "CheatEngine.Mcp", "Dependency.dll")); + break; + case "foreign file": + await File.WriteAllTextAsync(Path.Combine(fixture.Distribution, "CheatEngine.Mcp", "private.txt"), + "private", TestContext.Current.CancellationToken); + break; + case "mixed binaries": + File.Copy(typeof(string).Assembly.Location, Path.Combine(fixture.Distribution, "CheatEngine.Mcp.Gateway.exe"), true); + break; + } + + RunResult run = await fixture.RunAsync(); + + Assert.NotEqual(0, run.ExitCode); + Assert.Contains(message, run.Output, StringComparison.Ordinal); + Assert.Equal(original, FileHash(fixture.Zip)); + } + + [Fact] + public async Task Package_CorruptArchive_RebuildsAndVerifiesTheCompleteDistribution() + { + await using DistributionFixture fixture = new(); + Directory.CreateDirectory(fixture.Output); + await File.WriteAllTextAsync(fixture.Zip, "not a ZIP", TestContext.Current.CancellationToken); + + Assert.Equal(0, (await fixture.RunAsync()).ExitCode); + + using ZipArchive archive = ZipFile.OpenRead(fixture.Zip); + Assert.Contains(archive.Entries, static entry => entry.FullName == "CheatEngine.Mcp/CheatEngine.Mcp.Plugin.dll"); + Assert.DoesNotContain(Directory.GetFiles(fixture.Output), static path => path.EndsWith(".tmp", StringComparison.Ordinal)); + } + + [Fact] + public async Task Package_WhatIf_CreatesNoReleaseOutput() + { + await using DistributionFixture fixture = new(); + + RunResult run = await fixture.RunAsync("-WhatIf"); + + Assert.Equal(0, run.ExitCode); + Assert.False(Directory.Exists(fixture.Output)); + } + + [Fact] + public async Task Package_FailedCheckoutInspection_RefusesBeforeBuildOrOutput() + { + await using DistributionFixture fixture = new(); + + RunResult run = await fixture.RunWithFailedCheckoutAsync(); + + Assert.NotEqual(0, run.ExitCode); + Assert.Contains("git status failed; cannot confirm a clean checkout", run.Output, StringComparison.Ordinal); + Assert.False(Directory.Exists(fixture.Output)); + } + + [Fact] + public async Task Package_OutputInsideDistribution_RefusesWithoutWritingAssets() + { + await using DistributionFixture fixture = new(); + + RunResult run = await fixture.RunAsync("-OutputDirectory", Path.Combine(fixture.Distribution, "release")); + + Assert.NotEqual(0, run.ExitCode); + Assert.Contains("outside the distribution", run.Output, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(fixture.Distribution, "release"))); + } + + private static string FileHash(string path) + { + using Stream stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)); + } + + private sealed record RunResult(int ExitCode, string Output); + + private sealed class DistributionFixture : IAsyncDisposable + { + private readonly string _root = Path.Combine(RepositoryPaths.Root, "artifacts", "test-results", "release-script", + Guid.NewGuid().ToString("N")); + + internal DistributionFixture() + { + string plugin = Directory.CreateDirectory(Path.Combine(Distribution, "CheatEngine.Mcp")).FullName; + // These are metadata fixtures, not loadable plugin or gateway deployments. + string assembly = typeof(ToolDispatch).Assembly.Location; + foreach (string name in new[] { "CheatEngine.Mcp.Plugin.dll", "Dependency.dll", "cheatengine-sdk-lua-bridge.dll" }) + { + File.Copy(assembly, Path.Combine(plugin, name)); + } + File.Copy(assembly, Path.Combine(Distribution, "CheatEngine.Mcp.Gateway.exe")); + foreach (string name in new[] { "LICENSE", "THIRD-PARTY-NOTICES.md" }) + { + File.WriteAllText(Path.Combine(Distribution, name), "fixture notices"); + File.WriteAllText(Path.Combine(plugin, name), "fixture notices"); + } + foreach (string name in new[] { "README.md", "appsettings.json", "CheatEngine.Mcp.Plugin.runtimeconfig.json" }) + { + File.WriteAllText(Path.Combine(plugin, name), "{}"); + } + File.WriteAllText(Path.Combine(plugin, "CheatEngine.Mcp.Plugin.deps.json"), """ + {"targets":{"net10.0":{"fixture/1.0.0":{"runtime":{"CheatEngine.Mcp.Plugin.dll":{},"Dependency.dll":{}}}}}} + """); + string version = FileVersionInfo.GetVersionInfo(assembly).ProductVersion!.Split('+')[0]; + ZipName = $"CheatEngine.Mcp-{version}-win-x64.zip"; + } + + internal string Distribution => Path.Combine(_root, "distribution"); + internal string Output => Path.Combine(_root, "output"); + internal string Zip => Path.Combine(Output, ZipName); + internal string ZipName + { + get; + } + + public ValueTask DisposeAsync() + { + return new ValueTask(TestDirectory.DeleteAsync(_root)); + } + + internal Task RunWithFailedCheckoutAsync() + { + return RunAsync(true, ["-Upload"]); + } + + internal Task RunAsync(params string[] extra) + { + return RunAsync(false, extra); + } + + private async Task RunAsync(bool invalidCheckout, string[] extra) + { + ProcessStartInfo start = new("pwsh") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = RepositoryPaths.Root + }; + string script = Path.Combine(RepositoryPaths.Root, "eng", "Release.ps1"); + if (invalidCheckout) + { + string scripts = Directory.CreateDirectory(Path.Combine(_root, "eng")).FullName; + string copy = Path.Combine(scripts, "Release.ps1"); + File.Copy(script, copy); + script = copy; + start.Environment["GIT_DIR"] = Path.Combine(_root, "missing-git-directory"); + } + string[] arguments = ["-NoProfile", "-File", script]; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + if (!invalidCheckout) + { + start.ArgumentList.Add("-DistributionPath"); + start.ArgumentList.Add(Distribution); + } + if (!extra.Contains("-OutputDirectory", StringComparer.Ordinal)) + { + start.ArgumentList.Add("-OutputDirectory"); + start.ArgumentList.Add(Output); + } + foreach (string argument in extra) + { + start.ArgumentList.Add(argument); + } + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + using Process process = Process.Start(start)!; + Task output = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task error = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + return new RunResult(process.ExitCode, await output + await error); + } + finally + { + if (!process.HasExited) + { + process.Kill(true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + } + } +}