/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using System.Collections.Concurrent; using System.Diagnostics; using System.IO.Compression; using System.Net; using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using Xunit; namespace GitHub.Copilot.Test.Unit; /// /// Integration tests for the MSBuild targets shipped in /// dotnet/src/build/GitHub.Copilot.SDK.targets. Each test creates a throwaway /// project that imports the targets file directly and invokes dotnet build in /// a subprocess so we exercise real MSBuild evaluation. /// /// /// Download tests use a loopback release server; they never access the default GitHub URL. /// public class MSBuildTargetsTests { private static readonly string TargetsFilePath = FindTargetsFile(); private static readonly string BinaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; private static readonly string RuntimeWrapperName = OperatingSystem.IsWindows() ? "copilot-runtime.exe" : "copilot-runtime"; private static readonly string RuntimeLibraryName = OperatingSystem.IsWindows() ? "copilot_runtime.dll" : OperatingSystem.IsMacOS() ? "libcopilot_runtime.dylib" : "libcopilot_runtime.so"; [Fact] public async Task PreinstalledCliBinaryPath_IsHonored_DownloadSkipped_AndCopiedToOutput() { using var sandbox = MSBuildSandbox.Create(); var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, }); Assert.True(result.Succeeded, result.FailureMessage()); // Download message must be absent because the download target was skipped. Assert.DoesNotContain("Downloading Copilot CLI", result.StandardOutput, StringComparison.Ordinal); // Binary must be placed at the canonical runtimes path so Client.cs can locate it. var outputPath = sandbox.ExpectedOutputBinary(); Assert.True(File.Exists(outputPath), $"Expected CLI to be copied to '{outputPath}'.\n{result.FailureMessage()}"); Assert.Equal(File.ReadAllText(preinstalled), File.ReadAllText(outputPath)); Assert.True(File.Exists(Path.Combine(Path.GetDirectoryName(outputPath)!, ".copilot-explicit-cli"))); } [Fact] public async Task PreinstalledCliBinaryPath_NormalizesNonStandardFileNameToCanonical() { using var sandbox = MSBuildSandbox.Create(); // Use an off-spec source filename to confirm the copy task renames it to copilot[.exe]. var preinstalled = sandbox.WritePreinstalledBinary("custom-named", fileName: "my-copilot-binary-v1.bin"); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, }); Assert.True(result.Succeeded, result.FailureMessage()); var outputPath = sandbox.ExpectedOutputBinary(); Assert.True(File.Exists(outputPath), $"Expected canonical binary at '{outputPath}'.\n{result.FailureMessage()}"); } [Fact] public async Task SkipCliDownload_WithoutBinaryPath_ProducesNoBinaryAndSucceeds() { using var sandbox = MSBuildSandbox.Create(); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotSkipCliDownload"] = "true", }); Assert.True(result.Succeeded, result.FailureMessage()); // The runtimes folder may or may not be created by something else, but the binary // itself must not exist. Assert.False(File.Exists(sandbox.ExpectedOutputBinary()), $"Expected no CLI binary in output when CopilotSkipCliDownload=true and no path supplied.\n{result.FailureMessage()}"); // Download must also have been skipped. Assert.DoesNotContain("Downloading Copilot CLI", result.StandardOutput, StringComparison.Ordinal); } [Fact] public async Task PreinstalledCliBinaryPath_WithSkipCliDownload_StillCopiesToOutput() { using var sandbox = MSBuildSandbox.Create(); var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, ["CopilotSkipCliDownload"] = "true", }); Assert.True(result.Succeeded, result.FailureMessage()); Assert.True(File.Exists(sandbox.ExpectedOutputBinary()), result.FailureMessage()); } [Fact] public async Task ReleaseAsset_IsDownloadedVerifiedExtractedAndCached() { using var sandbox = MSBuildSandbox.Create(); var archive = sandbox.CreateReleaseArchive("release-runtime-wrapper"); var assetName = $"github-copilot-0.0.0-test-{GetReleasePlatform()}.tgz"; var assetPath = $"/v0.0.0-test/{assetName}"; var checksumsPath = "/v0.0.0-test/SHA256SUMS.txt"; var checksum = ComputeSha256(archive); using var server = new ReleaseServer(new Dictionary { [checksumsPath] = Encoding.UTF8.GetBytes($"{checksum} {assetName}\n"), [assetPath] = archive, }); var properties = new Dictionary { ["CopilotCliReleaseBaseUrl"] = server.BaseUrl, }; var firstBuild = await sandbox.BuildAsync(properties); Assert.True(firstBuild.Succeeded, firstBuild.FailureMessage()); Assert.Equal("release-runtime-wrapper", File.ReadAllText(sandbox.ExpectedOutputBinary())); Assert.Equal("release-runtime-wrapper", File.ReadAllText(sandbox.ExpectedRuntimeAsset(RuntimeWrapperName))); Assert.Equal("runtime", File.ReadAllText(sandbox.ExpectedRuntimeAsset("runtime.node"))); Assert.True(File.Exists(sandbox.ExpectedCacheAsset(".copilot-runtime-complete"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset(".copilot-runtime-complete"))); Assert.Equal(1, server.RequestPaths.Count(path => path == checksumsPath)); Assert.Equal(1, server.RequestPaths.Count(path => path == assetPath)); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("SHA256SUMS.txt"))); var secondBuild = await sandbox.BuildAsync(properties); Assert.True(secondBuild.Succeeded, secondBuild.FailureMessage()); Assert.Equal(2, server.RequestPaths.Count); } [Fact] public async Task IncompleteCache_WithRuntimePairButNoMarker_IsReacquired() { using var sandbox = MSBuildSandbox.Create(); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), RuntimeWrapperName, "partial-wrapper"); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), "runtime.node", "partial-runtime"); sandbox.WriteRuntimeCacheAsset("definitions", "stale.json", "stale"); var archive = sandbox.CreateReleaseArchive("complete-wrapper"); var assetName = $"github-copilot-0.0.0-test-{GetReleasePlatform()}.tgz"; var assetPath = $"/v0.0.0-test/{assetName}"; var checksumsPath = "/v0.0.0-test/SHA256SUMS.txt"; using var server = new ReleaseServer(new Dictionary { [checksumsPath] = Encoding.UTF8.GetBytes($"{ComputeSha256(archive)} {assetName}\n"), [assetPath] = archive, }); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliReleaseBaseUrl"] = server.BaseUrl, }); Assert.True(result.Succeeded, result.FailureMessage()); Assert.Equal(1, server.RequestPaths.Count(path => path == checksumsPath)); Assert.Equal(1, server.RequestPaths.Count(path => path == assetPath)); Assert.Equal("complete-wrapper", File.ReadAllText(sandbox.ExpectedRuntimeAsset(RuntimeWrapperName))); Assert.Equal("runtime", File.ReadAllText(sandbox.ExpectedRuntimeAsset("runtime.node"))); Assert.True(File.Exists(sandbox.ExpectedCacheAsset(".copilot-runtime-complete"))); Assert.False(File.Exists(sandbox.ExpectedCacheAsset("definitions", "stale.json"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("definitions", "stale.json"))); } [Fact] public async Task ReleaseAsset_WithChecksumMismatch_FailsBeforeExtraction() { using var sandbox = MSBuildSandbox.Create(); var archive = Encoding.UTF8.GetBytes("not the expected archive"); var assetName = $"github-copilot-0.0.0-test-{GetReleasePlatform()}.tgz"; using var server = new ReleaseServer(new Dictionary { ["/v0.0.0-test/SHA256SUMS.txt"] = Encoding.UTF8.GetBytes($"{new string('0', 64)} *{assetName}\n"), [$"/v0.0.0-test/{assetName}"] = archive, }); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliReleaseBaseUrl"] = server.BaseUrl, }); Assert.False(result.Succeeded, "Build should fail when the release checksum does not match."); Assert.Contains($"Checksum mismatch for {assetName}", result.StandardOutput, StringComparison.Ordinal); Assert.False(File.Exists(sandbox.ExpectedOutputBinary())); } [Fact] public async Task RuntimePackageAssets_AreFilteredAndCopiedToOutput() { using var sandbox = MSBuildSandbox.Create(); var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), "runtime.node", "runtime"); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), RuntimeWrapperName, "wrapper"); sandbox.WriteRuntimeCacheAsset("ripgrep", "bin", GetReleasePlatform(), "rg", "ripgrep"); sandbox.WriteRuntimeCacheAsset("definitions", "future.json", "{}"); sandbox.WriteRuntimeCacheAsset("copilot-sdk", "extension.js", "extension"); sandbox.WriteRuntimeCacheAsset("preloads", "extension_bootstrap.mjs", "preload"); sandbox.WriteRuntimeCacheAsset("sdk", "factory.js", "factory"); sandbox.WriteRuntimeCacheAsset("app.js", "excluded"); sandbox.WriteRuntimeCacheAsset("LICENSE.md", "excluded"); sandbox.WriteRuntimeCacheAsset("README.md", "excluded"); sandbox.WriteStaleOutputRuntimeAsset("obsolete", "tool", "stale"); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, }); Assert.True(result.Succeeded, result.FailureMessage()); Assert.Equal("ripgrep", File.ReadAllText(sandbox.ExpectedRuntimeAsset("ripgrep", "bin", GetReleasePlatform(), "rg"))); Assert.Equal("{}", File.ReadAllText(sandbox.ExpectedRuntimeAsset("definitions", "future.json"))); Assert.Equal("extension", File.ReadAllText(sandbox.ExpectedRuntimeAsset("copilot-sdk", "extension.js"))); Assert.Equal("preload", File.ReadAllText(sandbox.ExpectedRuntimeAsset("preloads", "extension_bootstrap.mjs"))); Assert.Equal("factory", File.ReadAllText(sandbox.ExpectedRuntimeAsset("sdk", "factory.js"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("app.js"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("LICENSE.md"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("README.md"))); Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("obsolete", "tool"))); } [Fact] public async Task PackAsTool_NoBuild_IncludesRuntimeAssetsInToolPackage() { using var sandbox = MSBuildSandbox.Create(packAsTool: true); var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), "runtime.node", "runtime"); sandbox.WriteRuntimeCacheAsset("prebuilds", GetReleasePlatform(), RuntimeWrapperName, "wrapper"); sandbox.WriteRuntimeCacheAsset("ripgrep", "bin", GetReleasePlatform(), "rg", "ripgrep"); var result = await sandbox.PackNoBuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, }); Assert.True(result.Succeeded, result.FailureMessage()); var rid = MSBuildSandbox.GetPortableRid(); var entries = sandbox.GetPackageEntries(); var nativePath = $"tools/net8.0/{rid}/runtimes/{rid}/native"; Assert.Contains($"{nativePath}/{BinaryName}", entries); Assert.Contains($"{nativePath}/{RuntimeWrapperName}", entries); Assert.Contains($"{nativePath}/runtime.node", entries); Assert.Contains($"{nativePath}/{RuntimeLibraryName}", entries); Assert.Contains($"{nativePath}/ripgrep/bin/{GetReleasePlatform()}/rg", entries); } [Fact] public async Task PackAsTool_NoBuild_PublishesExplicitCliMarker() { using var sandbox = MSBuildSandbox.Create(packAsTool: true); var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); var result = await sandbox.PackNoBuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = preinstalled, }); Assert.True(result.Succeeded, result.FailureMessage()); // The marker is what CopilotClient uses to launch an explicit CLI without // requiring the runtime pair, so it has to survive into the publish layout // the tool package is built from. NuGet's default dot-file exclusion keeps // it out of the .nupkg itself, which is a separate pre-existing limitation. Assert.True(File.Exists(sandbox.PublishedRuntimeAsset(".copilot-explicit-cli")), "The explicit-CLI marker must reach the publish layout."); Assert.True(File.Exists(sandbox.PublishedRuntimeAsset(BinaryName))); } [Fact] public async Task PreinstalledCliBinaryPath_NonExistentFile_FailsWithActionableError() { using var sandbox = MSBuildSandbox.Create(); var nonexistent = Path.Combine(sandbox.ProjectDir, "does-not-exist", BinaryName); var result = await sandbox.BuildAsync(new Dictionary { ["CopilotCliBinaryPath"] = nonexistent, }); Assert.False(result.Succeeded, "Build should have failed when CopilotCliBinaryPath points at a missing file."); Assert.Contains("Copilot CLI binary not found", result.StandardOutput, StringComparison.Ordinal); Assert.Contains(nonexistent, result.StandardOutput, StringComparison.Ordinal); } private static string FindTargetsFile([CallerFilePath] string? thisFile = null) { // thisFile == /dotnet/test/Unit/MSBuildTargetsTests.cs if (thisFile is not null && File.Exists(thisFile)) { var candidate = Path.GetFullPath(Path.Combine( Path.GetDirectoryName(thisFile)!, "..", "..", "src", "build", "GitHub.Copilot.SDK.targets")); if (File.Exists(candidate)) { return candidate; } } // Fall back to walking up from the test assembly location. var dir = AppContext.BaseDirectory; for (var i = 0; i < 8 && dir is not null; i++) { var candidate = Path.Combine(dir, "src", "build", "GitHub.Copilot.SDK.targets"); if (File.Exists(candidate)) { return candidate; } dir = Path.GetDirectoryName(dir); } throw new InvalidOperationException( "Could not locate GitHub.Copilot.SDK.targets relative to test assembly or source file."); } private static string GetReleasePlatform() { var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64"; if (OperatingSystem.IsWindows()) return $"win32-{arch}"; if (OperatingSystem.IsMacOS()) return $"darwin-{arch}"; var platform = IsMusl() ? "linuxmusl" : "linux"; return $"{platform}-{arch}"; } private static bool IsMusl() { #if NETFRAMEWORK return false; #else return System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier.StartsWith( "linux-musl-", StringComparison.Ordinal); #endif } private static string ComputeSha256(byte[] contents) { #if NETFRAMEWORK using var sha256 = SHA256.Create(); return BitConverter.ToString(sha256.ComputeHash(contents)).Replace("-", "").ToLowerInvariant(); #else return Convert.ToHexString(SHA256.HashData(contents)).ToLowerInvariant(); #endif } /// /// A throwaway directory containing a minimal csproj that imports the SDK targets /// file. Disposing removes the directory tree. /// private sealed class MSBuildSandbox : IDisposable { public string ProjectDir { get; } private MSBuildSandbox(string projectDir) { ProjectDir = projectDir; } public static MSBuildSandbox Create(bool packAsTool = false) { var dir = Path.Combine(Path.GetTempPath(), "copilot-sdk-targets-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); // Tool packages are produced from the publish layout, so the tool variant // exercises the packaging path reported in issue #2067. var toolProperties = packAsTool ? $""" Exe true CopilotSdkTargetsTest.Tool 1.0.0 copilot-sdk-targets-test {GetPortableRid()} false false """ : string.Empty; // Pin the RID for every sandbox build. Left unpinned, the targets fall // back to the spawned SDK's own RID, which need not match the test // process — so an emulated run would build for one architecture while // GetReleasePlatform() seeded the cache for another. var csproj = $""" net8.0 {GetPortableRid()} false 0.0.0-test true{toolProperties} """; File.WriteAllText(Path.Combine(dir, "App.csproj"), csproj); File.WriteAllText( Path.Combine(dir, "Stub.cs"), packAsTool ? "namespace CopilotSdkTargetsTest { internal static class Stub { private static void Main() { } } }\n" : "namespace CopilotSdkTargetsTest { internal static class Stub { } }\n"); return new MSBuildSandbox(dir); } public string WritePreinstalledBinary(string contents, string? fileName = null) { var preinstallDir = Path.Combine(ProjectDir, "preinstall"); Directory.CreateDirectory(preinstallDir); // Strip any path information from fileName so it cannot escape preinstallDir. var safeFileName = string.IsNullOrEmpty(fileName) ? BinaryName : Path.GetFileName(fileName); var path = Path.Combine(preinstallDir, safeFileName); File.WriteAllText(path, contents); return path; } public byte[] CreateReleaseArchive(string runtimeWrapperContents) { var sourceDir = Path.Combine(ProjectDir, "release-source"); var packageDir = Path.Combine(sourceDir, "package"); var prebuildDir = Path.Combine(packageDir, "prebuilds", GetReleasePlatform()); Directory.CreateDirectory(prebuildDir); File.WriteAllText(Path.Combine(prebuildDir, "runtime.node"), "runtime"); File.WriteAllText(Path.Combine(prebuildDir, RuntimeWrapperName), runtimeWrapperContents); var archivePath = Path.Combine(ProjectDir, "release-asset.tgz"); var tarPath = OperatingSystem.IsWindows() ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32", "tar.exe") : "tar"; var startInfo = new ProcessStartInfo(tarPath) { Arguments = $"-czf \"{archivePath}\" -C \"{sourceDir}\" package", RedirectStandardError = true, UseShellExecute = false, }; using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start tar while creating a release test asset."); var standardError = process.StandardError.ReadToEnd(); process.WaitForExit(); if (process.ExitCode != 0) { throw new InvalidOperationException($"tar failed while creating a release test asset: {standardError}"); } return File.ReadAllBytes(archivePath); } public string ExpectedOutputBinary() { var rid = GetPortableRid(); return Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", rid, "native", BinaryName); } public void WriteRuntimeCacheAsset(params string[] pathAndContents) { var pathParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray(); var path = ExpectedCacheAsset(pathParts); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, pathAndContents[^1]); } public string ExpectedCacheAsset(params string[] pathParts) { var path = Path.Combine(ProjectDir, "obj", "Debug", "net8.0", "copilot-cli", "0.0.0-test", GetReleasePlatform()); foreach (var part in pathParts) { path = Path.Combine(path, part); } return path; } /// /// A runtime asset in the publish layout, which is what tool packaging /// consumes. /// public string PublishedRuntimeAsset(params string[] pathParts) { var rid = GetPortableRid(); var path = Path.Combine(ProjectDir, "bin", "Debug", "net8.0", rid, "publish", "runtimes", rid, "native"); foreach (var part in pathParts) { path = Path.Combine(path, part); } return path; } public string ExpectedRuntimeAsset(params string[] pathParts) { var path = Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", GetPortableRid(), "native"); foreach (var part in pathParts) { path = Path.Combine(path, part); } return path; } public void WriteStaleOutputRuntimeAsset(params string[] pathAndContents) { var relativeParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray(); var path = ExpectedRuntimeAsset(relativeParts); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, pathAndContents[^1]); var manifest = ExpectedRuntimeAsset(".copilot-runtime-assets"); Directory.CreateDirectory(Path.GetDirectoryName(manifest)!); File.WriteAllText( manifest, string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts) + Environment.NewLine); } public async Task BuildAsync(IDictionary properties) => await RunAsync("build --nologo -clp:NoSummary", properties); /// /// Builds, publishes, then packs without rebuilding — the tool packaging sequence /// used by CI pipelines and reported in issue #2067. /// public async Task PackNoBuildAsync(IDictionary properties) { // Pin the configuration: build defaults to Debug while publish and pack default // to Release, which would otherwise stage and read different output trees. var rid = GetPortableRid(); var build = await RunAsync($"build --nologo -clp:NoSummary -c Debug -r {rid}", properties); if (!build.Succeeded) { return build; } var publish = await RunAsync($"publish --nologo -clp:NoSummary -c Debug -r {rid}", properties); if (!publish.Succeeded) { return publish; } return await RunAsync( $"pack --nologo -clp:NoSummary -c Debug -r {rid} --no-build -o packages", properties); } public List GetPackageEntries() { var package = Directory.GetFiles(Path.Combine(ProjectDir, "packages"), "*.nupkg").Single(); using var stream = File.OpenRead(package); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); return archive.Entries.Select(entry => entry.FullName).ToList(); } private async Task RunAsync(string command, IDictionary properties) { var args = new StringBuilder(command); foreach (var (key, value) in properties) { // Quote the value so paths with spaces are preserved. args.Append(" /p:").Append(key).Append('=').Append('"').Append(value).Append('"'); } var psi = new ProcessStartInfo("dotnet", args.ToString()) { WorkingDirectory = ProjectDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; // Avoid inheriting the parent's MSBuildSDKsPath/RuntimeIdentifier from the // running test host; the subprocess should resolve its own SDK and pick the // RID that matches ExpectedOutputBinary(). psi.Environment.Remove("MSBuildSDKsPath"); psi.Environment.Remove("RuntimeIdentifier"); using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start dotnet build subprocess."); // Drain both streams concurrently to avoid deadlocks on full pipe buffers. var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); // Generous timeout: dotnet restore + build of an empty project on a slow CI // worker can take ~60s the first time. We keep individual tests short by // using minimal projects. using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); try { await process.WaitForExitAsync(cts.Token); } catch (OperationCanceledException) { try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* process already exited */ } catch (NotSupportedException) { /* not supported on this platform */ } catch (System.ComponentModel.Win32Exception) { /* kill failed; best effort */ } throw new TimeoutException($"dotnet did not complete within the timeout for args: {args}"); } return new BuildResult( ExitCode: process.ExitCode, StandardOutput: await stdoutTask, StandardError: await stderrTask, CommandLine: $"dotnet {args}"); } public void Dispose() { try { Directory.Delete(ProjectDir, recursive: true); } catch (IOException) { /* cleanup is best effort */ } catch (UnauthorizedAccessException) { /* cleanup is best effort */ } } public static string GetPortableRid() { // Must agree with GetReleasePlatform(), which names the seeded runtime cache: // both describe the SDK actually running the build, so an emulated process // (x64 on arm64) pins the RID it seeded assets for. var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture; if (OperatingSystem.IsWindows()) { return arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "win-arm64", _ => "win-x64", }; } if (OperatingSystem.IsMacOS()) { return arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "osx-arm64", _ => "osx-x64", }; } var os = IsMusl() ? "linux-musl" : "linux"; var architecture = arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "arm64", _ => "x64", }; return $"{os}-{architecture}"; } } private sealed class ReleaseServer : IDisposable { private readonly IReadOnlyDictionary _responses; private readonly TcpListener _listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource _cancellation = new(); private readonly Task _serverTask; public ReleaseServer(IReadOnlyDictionary responses) { _responses = responses; _listener.Start(); var endpoint = (IPEndPoint)_listener.LocalEndpoint; BaseUrl = $"http://127.0.0.1:{endpoint.Port}"; _serverTask = ServeAsync(); } public string BaseUrl { get; } public ConcurrentQueue RequestPaths { get; } = new(); public void Dispose() { _cancellation.Cancel(); _listener.Stop(); try { _serverTask.GetAwaiter().GetResult(); } catch (OperationCanceledException) { } _cancellation.Dispose(); } private async Task ServeAsync() { while (!_cancellation.IsCancellationRequested) { TcpClient client; try { client = await _listener.AcceptTcpClientAsync(); } catch (ObjectDisposedException) when (_cancellation.IsCancellationRequested) { break; } catch (SocketException) when (_cancellation.IsCancellationRequested) { break; } await RespondAsync(client); } } private async Task RespondAsync(TcpClient client) { using (client) { var stream = client.GetStream(); using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true); var requestLine = await reader.ReadLineAsync(); string? header; do { header = await reader.ReadLineAsync(); } while (!string.IsNullOrEmpty(header)); var path = requestLine?.Split(' ', StringSplitOptions.RemoveEmptyEntries).ElementAtOrDefault(1) ?? ""; RequestPaths.Enqueue(path); var found = _responses.TryGetValue(path, out var body); body ??= Encoding.UTF8.GetBytes("Not found"); var status = found ? "200 OK" : "404 Not Found"; var responseHeaders = Encoding.ASCII.GetBytes( $"HTTP/1.1 {status}\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n"); await WriteBytesAsync(stream, responseHeaders); await WriteBytesAsync(stream, body); } } private static Task WriteBytesAsync(Stream stream, byte[] contents) { #if NETFRAMEWORK return stream.WriteAsync(contents, 0, contents.Length); #else return stream.WriteAsync(contents).AsTask(); #endif } } private sealed record BuildResult(int ExitCode, string StandardOutput, string StandardError, string CommandLine) { public bool Succeeded => ExitCode == 0; public string FailureMessage() => $"{CommandLine}\nExitCode: {ExitCode}\n--- STDOUT ---\n{StandardOutput}\n--- STDERR ---\n{StandardError}"; } }