diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets
index 9bc98f0f7..d03a8deaa 100644
--- a/dotnet/src/build/GitHub.Copilot.SDK.targets
+++ b/dotnet/src/build/GitHub.Copilot.SDK.targets
@@ -55,14 +55,33 @@
600
-
-
+
+
+ <_CopilotCliBinaryPath Condition="'$(CopilotCliBinaryPath)' != ''">$(CopilotCliBinaryPath)
+
+
+
+
<_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)
- <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)
+ <_CopilotCliBinaryPath Condition="'$(_CopilotCliBinaryPath)' == ''">$(_CopilotCacheDir)\$(_CopilotBinary)
<_CopilotArchivePath>$(_CopilotCacheDir)\copilot.tgz
<_CopilotNormalizedRegistryUrl>$([System.String]::Copy('$(CopilotNpmRegistryUrl)').TrimEnd('/'))
<_CopilotDownloadUrl>$(_CopilotNormalizedRegistryUrl)/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz
@@ -91,23 +110,29 @@
-
-
+
+
- <_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)
- <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)
+ <_CopilotCacheDir Condition="'$(_CopilotCacheDir)' == ''">$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)
+ <_CopilotCliBinaryPath Condition="'$(_CopilotCliBinaryPath)' == ''">$(_CopilotCacheDir)\$(_CopilotBinary)
<_CopilotOutputDir>$(OutDir)runtimes\$(_CopilotRid)\native
+
-
+
-
-
+
+
- <_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)
- <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)
+ <_CopilotCacheDir Condition="'$(_CopilotCacheDir)' == ''">$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)
+ <_CopilotCliBinaryPath Condition="'$(_CopilotCliBinaryPath)' == ''">$(_CopilotCacheDir)\$(_CopilotBinary)
+
+/// 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.
+///
+///
+/// These tests deliberately do not exercise the network-bound default download path; they
+/// pin a fake CopilotCliVersion and supply a fake CLI binary via
+/// CopilotCliBinaryPath. That is sufficient to cover the regression in issue
+/// #921 ("preinstalled CLI is ignored and copy/register are skipped when
+/// CopilotSkipCliDownload=true").
+///
+public class MSBuildTargetsTests
+{
+ private static readonly string TargetsFilePath = FindTargetsFile();
+
+ private static readonly string BinaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot";
+
+ [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));
+ }
+
+ [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 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.");
+ }
+
+ ///
+ /// 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()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "copilot-sdk-targets-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+
+ // 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 = $"""
+
+
+ net8.0
+ 0.0.0-test
+ true
+
+
+
+ """;
+ File.WriteAllText(Path.Combine(dir, "App.csproj"), csproj);
+ File.WriteAllText(Path.Combine(dir, "Stub.cs"), "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 string ExpectedOutputBinary()
+ {
+ var rid = GetPortableRid();
+ return Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", rid, "native", BinaryName);
+ }
+
+ public async Task BuildAsync(IDictionary properties)
+ {
+ var args = new StringBuilder("build --nologo -clp:NoSummary");
+ 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 build 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 */ }
+ }
+
+ private static string GetPortableRid()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.Arm64 => "win-arm64",
+ _ => "win-x64",
+ };
+ }
+ if (OperatingSystem.IsMacOS())
+ {
+ return System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.Arm64 => "osx-arm64",
+ _ => "osx-x64",
+ };
+ }
+ return System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.Arm64 => "linux-arm64",
+ _ => "linux-x64",
+ };
+ }
+ }
+
+ 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}";
+ }
+}