diff --git a/docs/building.md b/docs/building.md index c070430..53de2bf 100644 --- a/docs/building.md +++ b/docs/building.md @@ -75,7 +75,8 @@ bundle; its environment variables enable Developer ID signing and notarization. ## Server publishing -`dotnet/publish-server.ps1` produces locked self-contained Windows and Linux artifacts. +`dotnet/publish-server.ps1` produces locked self-contained Windows and Linux artifacts in one +command. Pass `-Runtime win-x64` or `-Runtime linux-x64` to publish only one target. `Dockerfile`, Compose configuration, and systemd packaging use the managed server. Validate a published binary with its TLS `--health-check`, preferably including `--expect-fingerprint`. diff --git a/docs/deployment.md b/docs/deployment.md index f220298..d0f244b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,7 +6,9 @@ The .NET server preserves protocol v2 and the native schema/credentials. Publish self-contained executable (no installed .NET runtime required): ```powershell +# Publishes both win-x64 and linux-x64 by default. ./dotnet/publish-server.ps1 +# Publish just one target when needed. ./dotnet/publish-server.ps1 -Runtime linux-x64 ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --help ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data @@ -52,8 +54,9 @@ backoff grows from one to thirty seconds. Success clears backoff but does not re tokens. State is bounded to 4096 keys; idle entries retire after ten minutes when full. Throttle and credential failures share the generic auth error. Limits are process-local. -The publish script uses separate per-RID lock files so deployment and development restore -graphs remain reproducible. Windows x64 and Linux x64 self-contained outputs are checked. +The publish script produces Windows x64 and Linux x64 self-contained outputs in one command +by default. It uses separate per-RID lock files so deployment and development restore graphs +remain reproducible. Pass `-Runtime` to publish only one target. The Linux smoke starts the published ELF, validates TLS health and the pin, checks mode 0700 data creation, sends SIGTERM and requires a clean exit: diff --git a/dotnet/publish-server.ps1 b/dotnet/publish-server.ps1 index e72cc65..7610f38 100644 --- a/dotnet/publish-server.ps1 +++ b/dotnet/publish-server.ps1 @@ -1,7 +1,17 @@ +[CmdletBinding(SupportsShouldProcess)] param( - [string]$Runtime = 'win-x64', - [string]$Output = "$PSScriptRoot/artifacts/server/$Runtime" + [string[]]$Runtime = @('win-x64', 'linux-x64'), + [string]$Output ) $ErrorActionPreference = 'Stop' -dotnet publish "$PSScriptRoot/src/VoiceCat.Server/VoiceCat.Server.csproj" -c Release -r $Runtime --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:RestoreLockedMode=true "-p:NuGetLockFilePath=packages.publish.$Runtime.lock.json" -o $Output -if ($LASTEXITCODE -ne 0) { throw 'Managed server publish failed.' } + +if ($Runtime.Count -eq 0) { throw 'At least one runtime must be specified.' } +if ($Output -and $Runtime.Count -ne 1) { throw '-Output can only be used when publishing one runtime.' } + +foreach ($targetRuntime in $Runtime) { + $targetOutput = if ($Output) { $Output } else { "$PSScriptRoot/artifacts/server/$targetRuntime" } + if (-not $PSCmdlet.ShouldProcess($targetOutput, "Publish VoiceCat.Server for $targetRuntime")) { continue } + + dotnet publish "$PSScriptRoot/src/VoiceCat.Server/VoiceCat.Server.csproj" -c Release -r $targetRuntime --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:RestoreLockedMode=true "-p:NuGetLockFilePath=packages.publish.$targetRuntime.lock.json" -o $targetOutput + if ($LASTEXITCODE -ne 0) { throw "Managed server publish failed for $targetRuntime." } +} diff --git a/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json index 0a58cc2..f5fa091 100644 --- a/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json +++ b/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json @@ -10,9 +10,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Google.Protobuf": { "type": "Transitive", diff --git a/dotnet/src/VoiceCat.Crypto/packages.publish.win-x64.lock.json b/dotnet/src/VoiceCat.Crypto/packages.publish.win-x64.lock.json index 59d8fae..1935814 100644 --- a/dotnet/src/VoiceCat.Crypto/packages.publish.win-x64.lock.json +++ b/dotnet/src/VoiceCat.Crypto/packages.publish.win-x64.lock.json @@ -10,9 +10,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Google.Protobuf": { "type": "Transitive", diff --git a/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json index 6afa3c4..b3cd39c 100644 --- a/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json +++ b/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json @@ -16,9 +16,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" } }, "net10.0/linux-x64": {} diff --git a/dotnet/src/VoiceCat.Protocol/packages.publish.win-x64.lock.json b/dotnet/src/VoiceCat.Protocol/packages.publish.win-x64.lock.json index b36d2a3..d81b227 100644 --- a/dotnet/src/VoiceCat.Protocol/packages.publish.win-x64.lock.json +++ b/dotnet/src/VoiceCat.Protocol/packages.publish.win-x64.lock.json @@ -16,9 +16,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" } }, "net10.0/win-x64": {} diff --git a/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json index feeb74b..bae5e59 100644 --- a/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json +++ b/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json @@ -13,9 +13,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "SourceGear.sqlite3": { "type": "Direct", diff --git a/dotnet/src/VoiceCat.Server/packages.publish.win-x64.lock.json b/dotnet/src/VoiceCat.Server/packages.publish.win-x64.lock.json index f5c49c3..a422df9 100644 --- a/dotnet/src/VoiceCat.Server/packages.publish.win-x64.lock.json +++ b/dotnet/src/VoiceCat.Server/packages.publish.win-x64.lock.json @@ -13,9 +13,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "SourceGear.sqlite3": { "type": "Direct", diff --git a/dotnet/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/dotnet/tests/VoiceCat.Tests/PublishServerScriptTests.cs new file mode 100644 index 0000000..c3b30f1 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace VoiceCat.Tests; + +public class PublishServerScriptTests +{ + [Fact] + public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne() + { + string script = Path.Combine(FindRoot(), "dotnet", "publish-server.ps1"); + + string allTargets = await RunWhatIf(script); + Assert.Contains("win-x64", allTargets); + Assert.Contains("linux-x64", allTargets); + + string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64"); + Assert.Contains("linux-x64", linuxOnly); + Assert.DoesNotContain("win-x64", linuxOnly); + } + + private static async Task RunWhatIf(string script, params string[] arguments) + { + string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh"; + var start = new ProcessStartInfo(powerShell) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments)) + start.ArgumentList.Add(argument); + + using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell."); + Task stdout = process.StandardOutput.ReadToEndAsync(); + Task stderr = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + string output = await stdout + await stderr; + Assert.True(process.ExitCode == 0, output); + return output; + } + + private static string FindRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "CMakePresets.json"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); + } +}