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