diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a30c211..8643668 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -393,6 +393,53 @@ $ mise exec -- which dotnet Those inputs make the gates runnable; they do not by themselves complete the runtime evidence. +#### Provisioning the runner + +The machine needs Windows x64, `git`, PowerShell 7 (`pwsh`), and a WSL +distribution holding a checkout with its own Linux `dotnet`. The psmux job +drives both sides from one PowerShell process, so a runner without WSL fails +the gate rather than skipping it. + +`pwsh` is the one a hosted runner would have supplied. Windows PowerShell 5.1 +is not it, and the job's steps ask for `pwsh` by name: + +```console +$ winget install --id Microsoft.PowerShell --silent +``` + +A Windows `dotnet` is not a prerequisite. `global.json` pins an exact SDK, so +the job installs that version into the runner's tool cache regardless of what +the machine already has. Restart the listener after installing anything it +needs to find on `PATH`; it reads the environment once, at start. + +Register it with the `psmux` label; `self-hosted`, `Windows` and `X64` are +added for you, and `runs-on` matches on all four: + +```console +$ ./config.cmd \ + --unattended \ + --replace \ + --url https://github.com/libtmux/libtmux-dotnet \ + --token "$(gh api -X POST \ + repos/libtmux/libtmux-dotnet/actions/runners/registration-token \ + --jq .token)" \ + --name psmux-wsl-win \ + --labels psmux +``` + +A registration token expires in an hour, so generate it when you use it. Run +the listener with `./run.cmd`, or install it as a service with `./svc.cmd +install` if it should survive a reboot. The runner must be online when the tag +is pushed: `psmux` has no `ubuntu-latest` fallback, so a queued job waits +rather than failing fast. + +Confirm what GitHub sees before relying on it: + +```console +$ gh api repos/libtmux/libtmux-dotnet/actions/runners \ + --jq '.runners[] | "\(.name) \(.status) [\([.labels[].name] | join(","))]"' +``` + ### Recorded evidence is a release artifact A capability row is `pending` until a matrix run records evidence for it, and diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa9714e..1088202 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,16 +101,35 @@ jobs: name: psmux native Windows and WSL needs: [validate, psmux-metadata] runs-on: [self-hosted, Windows, X64, psmux] - timeout-minutes: 45 + # The gate restores into a throwaway NuGet cache on purpose, so every run + # pays a full cold restore. That is the point of the isolation, and on a + # self-hosted Windows machine it is slower than the hosted equivalent. + timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # global.json pins an exact SDK, so this runner installs it rather than + # using whatever Windows already has. A self-hosted runner is an ordinary + # user account that cannot write to C:\Program Files\dotnet, so the + # install goes to the runner's own tool cache, which also survives runs. - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet with: global-json-file: global.json + # The smoke runs the net8.0 assemblies as well, and global.json only + # brings the pinned 10.x SDK and its runtime. A hosted image happens to + # carry .NET 8 already; an isolated install directory does not, so the + # job installs it rather than depending on what the machine has. + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet + with: + dotnet-version: '8.0' + - name: Download the audited psmux release shell: pwsh env: diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1 index e276f94..62ed2c2 100644 --- a/eng/psmux/Invoke-PsmuxSmoke.ps1 +++ b/eng/psmux/Invoke-PsmuxSmoke.ps1 @@ -113,6 +113,14 @@ elif [ "$requirement" != path ]; then fi printf '%s\n' "$candidate" '@ + +# git checks this file out with CRLF wherever core.autocrlf is on, and the +# scripts above are handed to /bin/sh inside WSL. A trailing carriage return +# turns the first line into "expecting in", so these are LF by construction +# rather than by however the checkout happened to land. +$wslTimeoutScript = $wslTimeoutScript -replace "`r`n", "`n" +$wslDotnetValidationScript = $wslDotnetValidationScript -replace "`r`n", "`n" +$wslPathResolutionScript = $wslPathResolutionScript -replace "`r`n", "`n" if ($ExpectedSha256 -ine $supportedSha256) { throw 'ExpectedSha256 must match the exact audited psmux client build.' } @@ -198,13 +206,21 @@ function Get-BoundedNativeErrorDetail([string] $ErrorText) { function Get-NativeExitMessage( [string] $Leg, [int] $ExitCode, - [string] $ErrorText) { + [string] $ErrorText, + [string] $OutputText = '') { $message = "$Leg exited $ExitCode." $detail = Get-BoundedNativeErrorDetail $ErrorText - if (-not $detail) { - return $message + if ($detail) { + return "$message stderr: $detail" + } + # A test runner reports the assertion that failed on stdout and leaves + # stderr empty, so an exit code on its own tells whoever reads the gate + # nothing about which check broke. + $detail = Get-BoundedNativeErrorDetail $OutputText + if ($detail) { + return "$message stdout: $detail" } - return "$message stderr: $detail" + return $message } function Assert-QueryProgram( @@ -336,7 +352,8 @@ function Invoke-CapturedNative( throw (Get-NativeExitMessage ` "$Leg process-tree termination" ` $treeKill.ExitCode ` - $treeKill.Error) + $treeKill.Error ` + $treeKill.Output) } } else { @@ -436,7 +453,8 @@ function Convert-ToWslPath( throw (Get-NativeExitMessage ` "WSL $Kind path $operation" ` $translation.ExitCode ` - $translation.Error) + $translation.Error ` + $translation.Output) } if ($translation.Output.Count -ne 1 -or [string]::IsNullOrWhiteSpace($translation.Output[0])) { @@ -595,8 +613,8 @@ try { $binaryText = [Text.Encoding]::ASCII.GetString( [IO.File]::ReadAllBytes($psmuxFile.FullName)) - if (-not $binaryText.Contains('aa26cd3') -or - -not $binaryText.Contains('2026-08-17')) { + if (-not $binaryText.Contains('66cf613') -or + -not $binaryText.Contains('2026-08-18')) { throw 'psmux does not contain the audited build markers.' } @@ -636,7 +654,8 @@ try { throw (Get-NativeExitMessage ` 'WSL .NET executable validation' ` $wslDotnetResult.ExitCode ` - $wslDotnetResult.Error) + $wslDotnetResult.Error ` + $wslDotnetResult.Output) } if ($wslDotnetResult.Output.Count -ne 1 -or [string]::IsNullOrWhiteSpace($wslDotnetResult.Output[0])) { @@ -662,7 +681,8 @@ try { throw (Get-NativeExitMessage ` 'psmux version query' ` $bannerResult.ExitCode ` - $bannerResult.Error) + $bannerResult.Error ` + $bannerResult.Output) } if ([string]::Join("`n", $banner) -cne [string]::Join("`n", $expectedBanner)) { @@ -687,7 +707,8 @@ try { throw (Get-NativeExitMessage ` 'psmux isolated namespace inspection' ` $existingResult.ExitCode ` - $existingResult.Error) + $existingResult.Error ` + $existingResult.Output) } if ($existing.Count -ne 0) { throw "The isolated namespace is not empty: $([string]::Join(', ', $existing))" @@ -743,7 +764,8 @@ try { throw (Get-NativeExitMessage ` 'psmux new-session after the creation attempt' ` $creationExitCode ` - $creationResult.Error) + $creationResult.Error ` + $creationResult.Output) } if (-not $createdSessionId) { throw 'psmux created no session with an exact verifiable identity.' @@ -764,7 +786,8 @@ try { throw (Get-NativeExitMessage ` 'psmux fixture input' ` $sendResult.ExitCode ` - $sendResult.Error) + $sendResult.Error ` + $sendResult.Output) } $ready = $false @@ -784,7 +807,8 @@ try { throw (Get-NativeExitMessage ` 'psmux pane capture' ` $captureResult.ExitCode ` - $captureResult.Error) + $captureResult.Error ` + $captureResult.Output) } $ready = $capture.Where({ $_ -clike "*$expectedText*" }).Count -gt 0 if (-not $ready) { @@ -820,7 +844,8 @@ try { throw (Get-NativeExitMessage ` 'Native Windows .NET smoke' ` $exitCode ` - $nativeTestResult.Error) + $nativeTestResult.Error ` + $nativeTestResult.Output) } Assert-OnePassingTest $nativeResultPath 'Native Windows .NET' $nativeExampleResult = Invoke-CapturedNative ` @@ -895,7 +920,8 @@ try { throw (Get-NativeExitMessage ` 'WSL .NET smoke' ` $exitCode ` - $wslTestResult.Error) + $wslTestResult.Error ` + $wslTestResult.Output) } Assert-OnePassingTest $wslResultPath 'WSL .NET' $wslExampleResult = Invoke-CapturedNative ` @@ -984,7 +1010,8 @@ finally { throw (Get-NativeExitMessage ` 'psmux cleanup identity query' ` $currentIdentityResult.ExitCode ` - $currentIdentityResult.Error) + $currentIdentityResult.Error ` + $currentIdentityResult.Output) } } elseif ($currentIdentity.Count -ne 1) { @@ -1006,7 +1033,8 @@ finally { throw (Get-NativeExitMessage ` 'psmux exact session cleanup' ` $killResult.ExitCode ` - $killResult.Error) + $killResult.Error ` + $killResult.Output) } for ($attempt = 0; $attempt -lt 50 -and diff --git a/eng/psmux/tests/test_smoke_harness.py b/eng/psmux/tests/test_smoke_harness.py index 045426a..538986c 100644 --- a/eng/psmux/tests/test_smoke_harness.py +++ b/eng/psmux/tests/test_smoke_harness.py @@ -25,8 +25,8 @@ def test_first_psmux_launch_uses_verified_binary_and_isolated_data() -> None: assert script.index("Get-FileHash", 0, first_launch) >= 0 assert script.index("$ExpectedSha256 -ine $supportedSha256", 0, first_launch) >= 0 assert "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d" in script - assert script.index("$binaryText.Contains('aa26cd3')", 0, first_launch) >= 0 - assert script.index("$binaryText.Contains('2026-08-17')", 0, first_launch) >= 0 + assert script.index("$binaryText.Contains('66cf613')", 0, first_launch) >= 0 + assert script.index("$binaryText.Contains('2026-08-18')", 0, first_launch) >= 0 assert script.index("$env:PSMUX_DATA_DIR = $DataDirectory", 0, first_launch) >= 0 assert script.index("$env:PSMUX_NO_WARM = '1'", 0, first_launch) >= 0 assert '"set -g warm off`n"' in script diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs index bda8242..c3d57ef 100644 --- a/src/LibTmux/Internal/PsmuxBinaryTrust.cs +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -45,8 +45,8 @@ private static async Task VerifyCoreAsync( BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var commit = new MarkerMatcher("aa26cd3"u8); - var date = new MarkerMatcher("2026-08-17"u8); + var commit = new MarkerMatcher("66cf613"u8); + var date = new MarkerMatcher("2026-08-18"u8); long total = 0; while (true) { diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs index 2e48f6c..7bc6ca5 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -153,8 +153,8 @@ public async Task Binary_trust_streams_hash_and_markers_across_buffer_boundaries $"libtmux-streamed-psmux-{Guid.NewGuid():N}.exe"); byte[] contents = new byte[82032]; Array.Fill(contents, (byte)'x'); - "aa26cd3"u8.CopyTo(contents.AsSpan(81917)); - "2026-08-17"u8.CopyTo(contents.AsSpan(82000)); + "66cf613"u8.CopyTo(contents.AsSpan(81917)); + "2026-08-18"u8.CopyTo(contents.AsSpan(82000)); await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); string hash = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(contents)); @@ -177,7 +177,7 @@ public async Task Binary_trust_does_not_capture_the_callers_synchronization_cont string binary = Path.Combine( Path.GetTempPath(), $"libtmux-context-psmux-{Guid.NewGuid():N}.exe"); - byte[] contents = "aa26cd3 2026-08-17"u8.ToArray(); + byte[] contents = "66cf613 2026-08-18"u8.ToArray(); await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); string hash = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(contents)); diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs index 4931595..8d248c0 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs @@ -36,7 +36,9 @@ public async Task Connect_and_typed_queries_use_audited_psmux() socketName), cancellationToken); - Assert.Equal(TmuxVersion.Parse("3.3.7"), server.Version); + Assert.Equal( + TmuxVersion.Parse(LibTmux.Internal.PsmuxCompatibility.SupportedVersion), + server.Version); PsmuxServer refreshed = await server.RefreshAsync(cancellationToken); Assert.Equal(server.Version, refreshed.Version);