diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index a5aba612ab..528901d290 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -215,9 +215,11 @@ - - + <_CopilotCacheDir Condition="'$(_CopilotCacheDir)' == ''">$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform) <_CopilotCliBinaryPath Condition="'$(_CopilotCliBinaryPath)' == ''">$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\$(_CopilotRuntimeWrapper) @@ -239,22 +241,27 @@ Condition="Exists('$(_CopilotRuntimeWrapperPath)') And Exists('$(_CopilotRuntimeNodePath)')" /> + CopyToOutputDirectory="PreserveNewest" + CopyToPublishDirectory="PreserveNewest" /> + CopyToOutputDirectory="PreserveNewest" + CopyToPublishDirectory="PreserveNewest" /> + CopyToOutputDirectory="PreserveNewest" + CopyToPublishDirectory="PreserveNewest" /> diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index 4b27df57c0..3f1c24f973 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -38,6 +38,7 @@ + diff --git a/dotnet/test/Unit/MSBuildTargetsTests.cs b/dotnet/test/Unit/MSBuildTargetsTests.cs index fd6ffa6ac8..17a8ea5502 100644 --- a/dotnet/test/Unit/MSBuildTargetsTests.cs +++ b/dotnet/test/Unit/MSBuildTargetsTests.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.IO.Compression; using System.Net; using System.Net.Sockets; using System.Runtime.CompilerServices; @@ -31,6 +32,11 @@ public class MSBuildTargetsTests 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() { @@ -236,6 +242,32 @@ public async Task RuntimePackageAssets_AreFilteredAndCopiedToOutput() 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 PreinstalledCliBinaryPath_NonExistentFile_FailsWithActionableError() { @@ -327,11 +359,28 @@ private MSBuildSandbox(string projectDir) ProjectDir = projectDir; } - public static MSBuildSandbox Create() + 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 + false + """ + : string.Empty; + // Minimal class library that imports the SDK targets with a pinned fake // CopilotCliVersion so the targets do not need the generated props file. var csproj = $""" @@ -339,13 +388,17 @@ public static MSBuildSandbox Create() net8.0 0.0.0-test - true + true{toolProperties} """; File.WriteAllText(Path.Combine(dir, "App.csproj"), csproj); - File.WriteAllText(Path.Combine(dir, "Stub.cs"), "namespace CopilotSdkTargetsTest { internal static class Stub { } }\n"); + 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); } @@ -439,9 +492,46 @@ public void WriteStaleOutputRuntimeAsset(params string[] pathAndContents) string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts) + Environment.NewLine); } - public async Task BuildAsync(IDictionary properties) + 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("build --nologo -clp:NoSummary"); + var args = new StringBuilder(command); foreach (var (key, value) in properties) { // Quote the value so paths with spaces are preserved. @@ -482,7 +572,7 @@ public async Task BuildAsync(IDictionary properties catch (InvalidOperationException) { /* process already exited */ } catch (NotSupportedException) { /* not supported on this platform */ } catch (System.ComponentModel.Win32Exception) { /* kill failed; best effort */ } - throw new TimeoutException($"dotnet build did not complete within the timeout for args: {args}"); + throw new TimeoutException($"dotnet did not complete within the timeout for args: {args}"); } return new BuildResult( @@ -499,11 +589,15 @@ public void Dispose() catch (UnauthorizedAccessException) { /* cleanup is best effort */ } } - private static string GetPortableRid() + 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 System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + return arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "win-arm64", _ => "win-x64", @@ -511,14 +605,14 @@ private static string GetPortableRid() } if (OperatingSystem.IsMacOS()) { - return System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + return arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "osx-arm64", _ => "osx-x64", }; } var os = IsMusl() ? "linux-musl" : "linux"; - var architecture = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + var architecture = arch switch { System.Runtime.InteropServices.Architecture.Arm64 => "arm64", _ => "x64",