diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 1da3ba63..ed8746bb 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -11,9 +11,12 @@ CultNet, and CultCache ownership boundaries. powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1 ``` -The command creates a local feed under `artifacts/nuget`, verifies that every -internal dependency package exists at the same version, then restores and runs -a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`. +The command packs `GameCult.Mesh`, `GameCult.Networking.WebSockets`, and every +project they reach through `ProjectReference` (including `GameCult.Math`, which +carries its own version) into a local feed under `artifacts/nuget`. It verifies +that each of those packages exists at the identity MSBuild reports for it, then +restores and runs a clean .NET consumer using only +`PackageReference Include="GameCult.Mesh"`. The Unity distribution remains `org.gamecult.cultlib`, built by `scripts/build-unity-package.ps1`, because Unity consumes the managed assembly diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs index c26d654b..a8cc3429 100644 --- a/samples/eve-browser-network/Program.cs +++ b/samples/eve-browser-network/Program.cs @@ -244,11 +244,13 @@ static async Task RunHeadlessAsync(Args arguments) Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = initial.Count })); } if (initial.Count >= arguments.ExpectedCount) completion.TrySetResult(); + var commandReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var commandTask = Task.Run(async () => { var line = await Console.In.ReadLineAsync(); if (line == null || !line.StartsWith("INVOKE ", StringComparison.Ordinal)) throw new InvalidOperationException("The headless sample expected 'INVOKE ' on stdin."); + commandReceived.TrySetResult(); var commandId = line["INVOKE ".Length..].Trim(); var result = await mesh.InvokeAsync( target, @@ -268,6 +270,9 @@ static async Task RunHeadlessAsync(Args arguments) Console.WriteLine("HEADLESS_NETWORK_BENCHMARK " + JsonSerializer.Serialize( await MeasureOperationSessionAsync(mesh, target, arguments))); }); + // How long the caller takes to send the command is the caller's to bound + // (closing stdin ends the wait); this process bounds only its own work. + await Task.WhenAny(commandReceived.Task, commandTask); await Task.WhenAll(completion.Task, commandTask).WaitAsync(TimeSpan.FromSeconds(45)); } diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 index 95e5cd2f..46e0192c 100644 --- a/scripts/pack-nuget.ps1 +++ b/scripts/pack-nuget.ps1 @@ -13,55 +13,90 @@ $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { Join-Path $repoRoot $OutputDirectory } -$projects = @( - "src\GameCult.Logging\GameCult.Logging.csproj", - "src\GameCult.Caching\GameCult.Caching.csproj", - "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", - "src\GameCult.Networking\GameCult.Networking.csproj", - "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", - "src\GameCult.Mesh\GameCult.Mesh.csproj" +# The public packages a consumer references directly. Everything they reach +# through ProjectReference is part of the published closure and is packed too, +# so a new internal dependency cannot leave the feed short of a package. +$rootProjects = @( + "src\GameCult.Mesh\GameCult.Mesh.csproj", + "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" ) +# MSBuild paths use backslashes; normalize them so the closure walk also +# resolves on Linux and macOS. +function Resolve-ProjectPath([string] $path) { + return [IO.Path]::GetFullPath($path.Replace('\', [string][IO.Path]::DirectorySeparatorChar)) +} + +function Get-ProjectReferenceClosure([string[]] $roots) { + $seen = [System.Collections.Generic.List[string]]::new() + $pending = [System.Collections.Generic.Queue[string]]::new() + foreach ($root in $roots) { $pending.Enqueue((Resolve-ProjectPath $root)) } + while ($pending.Count -gt 0) { + $project = $pending.Dequeue() + if ($seen.Contains($project)) { continue } + if (-not (Test-Path -LiteralPath $project -PathType Leaf)) { + throw "Project in the NuGet closure was not found: $project" + } + $seen.Add($project) + # Every ProjectReference counts, whatever its Condition: a reference + # present for any target framework ships in the package's dependencies. + foreach ($reference in ([xml](Get-Content -LiteralPath $project -Raw)).SelectNodes("//*[local-name()='ProjectReference']")) { + $include = $reference.GetAttribute("Include").Trim() + if ($include -match '\$\(') { + throw "Cannot resolve the MSBuild property in ProjectReference '$include' from $project" + } + $pending.Enqueue((Resolve-ProjectPath (Join-Path (Split-Path -Parent $project) $include))) + } + } + return $seen +} + +$projects = Get-ProjectReferenceClosure ($rootProjects | ForEach-Object { Join-Path $repoRoot $_ }) + if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +$versionArguments = @() +if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { + $versionArguments += "-p:CultLibPackageVersion=$PackageVersion" +} + +$expectedPackages = @() foreach ($project in $projects) { - $packArguments = @( - "pack", (Join-Path $repoRoot $project), "-c", $Configuration, "-o", $outputRoot, - "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632" - ) - if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { - $packArguments += "-p:CultLibPackageVersion=$PackageVersion" + # The package identity comes from MSBuild itself: GameCult.Math, for one, + # carries its own PackageId and version rather than the CultLib version. + $identity = (& dotnet msbuild $project -nologo "-getProperty:PackageId" "-getProperty:Version" @versionArguments) | + Out-String | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { + throw "Could not read the package identity of $project" } + $expectedPackages += "$($identity.Properties.PackageId).$($identity.Properties.Version).nupkg" + + $packArguments = @( + "pack", $project, "-c", $Configuration, "-o", $outputRoot, + "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632" + ) + $versionArguments & dotnet @packArguments if ($LASTEXITCODE -ne 0) { throw "NuGet pack failed for $project with exit code $LASTEXITCODE" } } +foreach ($package in $expectedPackages) { + if (-not (Test-Path -LiteralPath (Join-Path $outputRoot $package))) { + throw "NuGet dependency closure is missing $package" + } +} + $meshPackage = Get-ChildItem -LiteralPath $outputRoot -Filter "GameCult.Mesh.*.nupkg" | Where-Object { $_.Name -notlike "*.snupkg" } | Select-Object -First 1 if (-not $meshPackage) { throw "GameCult.Mesh package was not produced." } - $version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Groups[1].Value -$expectedPackages = @( - "GameCult.Logging.$version.nupkg", - "GameCult.Caching.$version.nupkg", - "GameCult.Caching.MessagePack.$version.nupkg", - "GameCult.Networking.$version.nupkg", - "GameCult.Networking.WebSockets.$version.nupkg", - "GameCult.Mesh.$version.nupkg" -) -foreach ($package in $expectedPackages) { - if (-not (Test-Path -LiteralPath (Join-Path $outputRoot $package))) { - throw "NuGet dependency closure is missing $package" - } -} if (-not $SkipConsumerSmoke) { $smokeRoot = Join-Path $repoRoot "artifacts\nuget-consumer-smoke"