Merge portability onboarding fix: pack the whole public closure; time the headless run from its command

NU1101 since 69bd45ff: pack-nuget.ps1 packed a hand list that missed
GameCult.Math; it now follows ProjectReferences from the public roots.
ubuntu timeout since 2dc7c122: the headless deadline started at connect.
Green on both OSes: run 36727267703 (ubuntu passed twice).
Follow-up (Eve): pack-dotnet-surface.ps1 ignores the version parameters
the verify script passes, so the local feed reuses published versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 16:19:31 +02:00
commit 82f976b933
3 changed files with 72 additions and 29 deletions

View file

@ -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

View file

@ -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 <idempotency-key>' on stdin.");
commandReceived.TrySetResult();
var commandId = line["INVOKE ".Length..].Trim();
var result = await mesh.InvokeAsync<IncrementRequest, IncrementReceipt>(
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));
}

View file

@ -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"