From 9b4167d9abf9c2b625920964ec6e74910faa7ec0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:02:08 +0200 Subject: [PATCH 1/2] pack-nuget: pack the ProjectReference closure, not a hand list Merge 69bd45ff made GameCult.Caching.MessagePack reference CultMath (PackageId GameCult.Math), so its nuspec depends on GameCult.Math 0.2.4. The packer's hand-written project list never packed it, and GameCult.Math is not on nuget.org, so every clean consumer of the local feed failed restore with NU1101. That is the Eve surface consumer smoke in the "one-command multiplayer onboarding" jobs, red on both OSes since then. The packer now walks ProjectReference from the public roots (GameCult.Mesh, GameCult.Networking.WebSockets) and packs the whole closure. The expected package names come from MSBuild's PackageId and Version for each project, since GameCult.Math carries its own identity. Co-Authored-By: Claude Opus 5.5 --- docs/nuget-packaging.md | 9 +++-- scripts/pack-nuget.ps1 | 89 ++++++++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 30 deletions(-) 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/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" From 04c0171bcc2688db9515594ccf35f933b8def68a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:11:41 +0200 Subject: [PATCH 2/2] eve-browser-network: the headless deadline starts at its command The headless C# client armed a 45 s deadline as soon as its lease was up, but the orchestrator then runs the whole browser scenario (Chromium launch, two clicks, provider and Odin replacement, page reconnect) before it sends INVOKE. On ubuntu runners that scenario alone takes most of the budget: the last green run finished with little to spare, and since 2dc7c122 the ubuntu onboarding job has timed out in the headless process either before the receipt or during the post-receipt benchmark. The orchestrator already bounds each step it waits on, and closing stdin ends the headless wait for its command. The headless process now bounds only its own work: the 45 s deadline covers the invoke, convergence and benchmark after INVOKE arrives. Co-Authored-By: Claude Opus 5.5 --- samples/eve-browser-network/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) 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)); }