Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions dotnet/src/build/GitHub.Copilot.SDK.targets
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,11 @@
<Delete Files="$(_CopilotOutputDir)\$(_CopilotRuntimeLib)" Condition="!Exists('$(_CopilotRuntimeNodePath)')" />
</Target>

<!-- Register the compatibility entrypoint as content so it flows through project references.
<!-- Register the compatibility entrypoint as content so it flows through project references
and publish output. Tool packages (PackAsTool) are built from the publish layout, so the
runtime assets must be registered for publish as well as build output.
Same gating semantics as _CopyCopilotCliToOutput. -->
<Target Name="_RegisterCopilotCliForCopy" BeforeTargets="GetCopyToOutputDirectoryItems" DependsOnTargets="_DownloadCopilotCli" Condition="'$(TargetFramework)' != '' And ('$(CopilotCliBinaryPath)' != '' Or '$(CopilotSkipCliDownload)' != 'true') And '$(_CopilotPlatform)' != ''">
<Target Name="_RegisterCopilotCliForCopy" BeforeTargets="GetCopyToOutputDirectoryItems;GetCopyToPublishDirectoryItems" DependsOnTargets="_DownloadCopilotCli" Condition="'$(TargetFramework)' != '' And ('$(CopilotCliBinaryPath)' != '' Or '$(CopilotSkipCliDownload)' != 'true') And '$(_CopilotPlatform)' != ''">
<PropertyGroup>
<_CopilotCacheDir Condition="'$(_CopilotCacheDir)' == ''">$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)</_CopilotCacheDir>
<_CopilotCliBinaryPath Condition="'$(_CopilotCliBinaryPath)' == ''">$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\$(_CopilotRuntimeWrapper)</_CopilotCliBinaryPath>
Expand All @@ -239,22 +241,27 @@
Condition="Exists('$(_CopilotRuntimeWrapperPath)') And Exists('$(_CopilotRuntimeNodePath)')" />
<ContentWithTargetPath Include="$(_CopilotCliBinaryPath)"
TargetPath="runtimes\$(_CopilotRid)\native\$(_CopilotBinary)"
CopyToOutputDirectory="PreserveNewest" />
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
<ContentWithTargetPath Include="$(_CopilotExplicitCliMarker)"
TargetPath="runtimes\$(_CopilotRid)\native\.copilot-explicit-cli"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
Condition="'$(CopilotCliBinaryPath)' != ''" />
<!-- FFI cdylib (only when the tarball ships it); see _CopyCopilotCliToOutput. -->
<ContentWithTargetPath Include="$(_CopilotRuntimeNodePath)"
TargetPath="runtimes\$(_CopilotRid)\native\$(_CopilotRuntimeLib)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
Condition="Exists('$(_CopilotRuntimeNodePath)')" />
<ContentWithTargetPath Include="@(_CopilotRuntimeRootAsset)"
TargetPath="runtimes\$(_CopilotRid)\native\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
<ContentWithTargetPath Include="@(_CopilotRuntimePrebuildAsset)"
TargetPath="runtimes\$(_CopilotRid)\native\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
</Project>
1 change: 1 addition & 0 deletions dotnet/test/GitHub.Copilot.SDK.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<Compile Include="..\src\Polyfills\*.cs" Link="Polyfills\%(Filename)%(Extension)" />
<PackageReference Include="Microsoft.Bcl.Memory" />
<PackageReference Include="System.Net.Http.Json" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.Net.Http" />
</ItemGroup>

Expand Down
104 changes: 97 additions & 7 deletions dotnet/test/Unit/MSBuildTargetsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<string, string>
{
["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()
{
Expand Down Expand Up @@ -327,25 +359,46 @@ 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
? $"""

<OutputType>Exe</OutputType>
<PackAsTool>true</PackAsTool>
<PackageId>CopilotSdkTargetsTest.Tool</PackageId>
<Version>1.0.0</Version>
<ToolCommandName>copilot-sdk-targets-test</ToolCommandName>
<RuntimeIdentifiers>{GetPortableRid()}</RuntimeIdentifiers>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<SelfContained>false</SelfContained>
<UseAppHost>false</UseAppHost>
"""
: 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 = $"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<CopilotCliVersion>0.0.0-test</CopilotCliVersion>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>{toolProperties}
</PropertyGroup>
<Import Project="{TargetsFilePath}" />
</Project>
""";
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);
}
Expand Down Expand Up @@ -439,9 +492,46 @@ public void WriteStaleOutputRuntimeAsset(params string[] pathAndContents)
string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts) + Environment.NewLine);
}

public async Task<BuildResult> BuildAsync(IDictionary<string, string> properties)
public async Task<BuildResult> BuildAsync(IDictionary<string, string> properties) =>
await RunAsync("build --nologo -clp:NoSummary", properties);

/// <summary>
/// Builds, publishes, then packs without rebuilding — the tool packaging sequence
/// used by CI pipelines and reported in issue #2067.
/// </summary>
public async Task<BuildResult> PackNoBuildAsync(IDictionary<string, string> 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<string> 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<BuildResult> RunAsync(string command, IDictionary<string, string> 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.
Expand Down Expand Up @@ -482,7 +572,7 @@ public async Task<BuildResult> BuildAsync(IDictionary<string, string> 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(
Expand All @@ -499,7 +589,7 @@ public void Dispose()
catch (UnauthorizedAccessException) { /* cleanup is best effort */ }
}

private static string GetPortableRid()
public static string GetPortableRid()
{
if (OperatingSystem.IsWindows())
{
Expand Down