diff --git a/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs b/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs
new file mode 100644
index 0000000..e07d846
--- /dev/null
+++ b/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs
@@ -0,0 +1,228 @@
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Text.Json;
+using System.Xml;
+using System.Xml.Linq;
+using LibGit2Sharp;
+using PackageCascade.Cli;
+
+namespace PackageCascade.Tests.Integration.CliEndToEnd;
+
+public sealed class PrivateReferenceApplyProcessTests : IDisposable
+{
+ private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-private-reference-").FullName;
+ private readonly Repository _repository;
+ private readonly string _solutionPath;
+ private readonly string _outputPath;
+
+ public PrivateReferenceApplyProcessTests()
+ {
+ Repository.Init(_directory);
+ _repository = new(_directory);
+ _solutionPath = Path.Combine(_directory, "Packages.slnx");
+ _outputPath = Path.Combine(_directory, "packages");
+
+ File.WriteAllText(Path.Combine(_directory, ".gitignore"), "**/bin/\n**/obj/\n");
+ File.WriteAllText(Path.Combine(_directory, "global.json"), """
+ {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true,"rollForward":"latestPatch"}}
+ """);
+ File.WriteAllText(Path.Combine(_directory, "NuGet.Config"),
+ "");
+ File.WriteAllText(Path.Combine(_directory, "Directory.Build.props"),
+ "false");
+ File.WriteAllText(_solutionPath, """
+
+
+
+
+ """);
+
+ string aDirectory = Directory.CreateDirectory(Path.Combine(_directory, "A")).FullName;
+ string bDirectory = Directory.CreateDirectory(Path.Combine(_directory, "B")).FullName;
+ File.WriteAllText(Path.Combine(aDirectory, "A.csproj"), """
+
+ net11.0
+
+
+ """);
+ File.WriteAllText(Path.Combine(aDirectory, "Api.cs"),
+ "public static class A { public static int Value => B.Value; }\n");
+ File.WriteAllText(Path.Combine(bDirectory, "B.csproj"), """
+
+ net11.0
+
+ """);
+ File.WriteAllText(Path.Combine(bDirectory, "Api.cs"),
+ "public static class B { public static int Value => 42; }\n");
+
+ Commit baseline = Commit("chore: add packages", ".gitignore", "global.json", "NuGet.Config",
+ "Directory.Build.props", "Packages.slnx", "A/A.csproj", "A/Api.cs", "B/B.csproj", "B/Api.cs");
+ _repository.Tags.Add("A-v2.0.0", baseline);
+ _repository.Tags.Add("B-v1.2.3", baseline);
+
+ File.WriteAllText(Path.Combine(bDirectory, "Api.cs"),
+ "public static class B { public static int Value => 43; }\n");
+ Commit("fix: repair B value", "B/Api.cs");
+ }
+
+ [Test]
+ public async Task BuiltCliPipesPrivateReferencePlanToApply(CancellationToken cancellationToken)
+ {
+ using Process apply = StartProcess(redirectInput: true,
+ "apply", "--repository", _directory, "--output", _outputPath);
+ using Process plan = StartProcess(redirectInput: false,
+ "plan", _solutionPath, "--repository", _directory);
+ Task planError = plan.StandardError.ReadToEndAsync(cancellationToken);
+ Task applyOutput = apply.StandardOutput.ReadToEndAsync(cancellationToken);
+ Task applyError = apply.StandardError.ReadToEndAsync(cancellationToken);
+
+ try
+ {
+ await plan.StandardOutput.BaseStream.CopyToAsync(apply.StandardInput.BaseStream, cancellationToken);
+ apply.StandardInput.Close();
+ await Task.WhenAll(plan.WaitForExitAsync(cancellationToken), apply.WaitForExitAsync(cancellationToken));
+ using (Assert.Multiple())
+ {
+ await Assert.That(plan.ExitCode).IsEqualTo(0).Because(await planError);
+ await Assert.That(apply.ExitCode).IsEqualTo(0).Because(await applyError);
+ }
+
+ await AssertApplied(await applyOutput, cancellationToken);
+ }
+ finally
+ {
+ await StopIfRunning(plan);
+ await StopIfRunning(apply);
+ }
+ }
+
+ [Test]
+ public async Task BuiltCliAppliesPrivateReferencePlanFromFile(CancellationToken cancellationToken)
+ {
+ string planPath = Path.Combine(_directory, "plan.json");
+ (int planExitCode, string planOutput, string planError) = await Run(cancellationToken,
+ "plan", _solutionPath, "--repository", _directory, "--outfile", planPath);
+ using (Assert.Multiple())
+ {
+ await Assert.That(planExitCode).IsEqualTo(0).Because(planError);
+ await Assert.That(planOutput).IsEmpty();
+ await Assert.That(File.Exists(planPath)).IsTrue();
+ }
+
+ (int applyExitCode, string applyOutput, string applyError) = await Run(cancellationToken,
+ "apply", "--repository", _directory, "--infile", planPath, "--output", _outputPath);
+ await Assert.That(applyExitCode).IsEqualTo(0).Because(applyError);
+ await AssertApplied(applyOutput, cancellationToken);
+ }
+
+ private async Task AssertApplied(string output, CancellationToken cancellationToken)
+ {
+ using JsonDocument document = JsonDocument.Parse(output);
+ JsonElement result = document.RootElement;
+ string[] packagePaths = result.GetProperty("PackagePaths").EnumerateArray()
+ .Select(path => path.GetString()!).ToArray();
+ string[] createdTags = result.GetProperty("CreatedTags").EnumerateArray()
+ .Select(tag => tag.GetString()!).ToArray();
+ string[] expectedPaths = [
+ Path.Combine(_outputPath, "B.1.2.4.nupkg"),
+ Path.Combine(_outputPath, "A.2.0.1.nupkg"),
+ ];
+ XElement bMetadata = await ReadMetadata(expectedPaths[0], cancellationToken);
+ XElement aMetadata = await ReadMetadata(expectedPaths[1], cancellationToken);
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(packagePaths).IsEquivalentTo(expectedPaths);
+ await Assert.That(Directory.GetFiles(_outputPath, "*.nupkg")).IsEquivalentTo(expectedPaths);
+ await Assert.That(createdTags).IsEquivalentTo(new[] { "B-v1.2.4", "A-v2.0.1" });
+ await Assert.That(_repository.Tags["B-v1.2.4"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha);
+ await Assert.That(_repository.Tags["A-v2.0.1"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha);
+ await Assert.That(_repository.Tags["B-v1.2.3"]).IsNotNull();
+ await Assert.That(_repository.Tags["A-v2.0.0"]).IsNotNull();
+ await AssertIdentity(bMetadata, "B", "1.2.4");
+ await AssertIdentity(aMetadata, "A", "2.0.1");
+ await Assert.That(aMetadata.Descendants().Any(element => element.Name.LocalName == "dependency" &&
+ (string?)element.Attribute("id") == "B")).IsFalse();
+ }
+ }
+
+ private static async Task AssertIdentity(XElement metadata, string id, string version)
+ {
+ await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "id").Value).IsEqualTo(id);
+ await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "version").Value).IsEqualTo(version);
+ }
+
+ private static async Task ReadMetadata(string path, CancellationToken cancellationToken)
+ {
+ await using FileStream file = new(path, FileMode.Open, FileAccess.Read, FileShare.Read,
+ bufferSize: 4096, options: FileOptions.Asynchronous);
+ await using ZipArchive archive = await ZipArchive.CreateAsync(file, ZipArchiveMode.Read,
+ leaveOpen: true, entryNameEncoding: null, cancellationToken: cancellationToken);
+ ZipArchiveEntry manifest = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal));
+ await using Stream stream = await manifest.OpenAsync(cancellationToken);
+ using XmlReader reader = XmlReader.Create(stream, new XmlReaderSettings { Async = true });
+ XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
+ return document.Root!.Elements().Single(element => element.Name.LocalName == "metadata");
+ }
+
+ private Process StartProcess(bool redirectInput, params string[] arguments)
+ {
+ ProcessStartInfo startInfo = new("dotnet")
+ {
+ WorkingDirectory = _directory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = redirectInput,
+ };
+ startInfo.ArgumentList.Add(typeof(Program).Assembly.Location);
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ return Process.Start(startInfo)!;
+ }
+
+ private async Task<(int ExitCode, string Output, string Error)> Run(
+ CancellationToken cancellationToken, params string[] arguments)
+ {
+ using Process process = StartProcess(redirectInput: false, arguments);
+ Task output = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ Task error = process.StandardError.ReadToEndAsync(cancellationToken);
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken);
+ return (process.ExitCode, await output, await error);
+ }
+ finally
+ {
+ await StopIfRunning(process);
+ }
+ }
+
+ private static async Task StopIfRunning(Process process)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ }
+
+ private Commit Commit(string message, params string[] paths)
+ {
+ foreach (string path in paths)
+ {
+ Commands.Stage(_repository, path);
+ }
+
+ Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow);
+ return _repository.Commit(message, author, author);
+ }
+
+ public void Dispose()
+ {
+ _repository.Dispose();
+ Directory.Delete(_directory, recursive: true);
+ }
+}
diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs
index 6c0df0e..5925e39 100644
--- a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs
+++ b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs
@@ -95,6 +95,24 @@ public async Task LibraryFeatureReleasesOnlyLibraryAndItsDependants()
}
}
+ [Test]
+ public async Task PrivateReferenceStillCascadesAndOrdersReleasesWithoutPackageDependency()
+ {
+ PackableProject application = Project("A", "B") with { PackageDependencyProjectPaths = [] };
+ ReleasePlan plan = Generate([application, Project("B", "C"), Project("C")], Tags(),
+ [Commit("fix-b", "fix: repair B", "src/B/Api.cs")]);
+
+ using (Assert.Multiple())
+ {
+ await AssertVersions(plan, ("A", "1.2.4"), ("B", "2.3.5"));
+ await AssertDependencyReason(plan, "A", "B");
+ await Assert.That(string.Join(",", plan.Packages.Select(package => package.PackageId))).IsEqualTo("B,A");
+ await Assert.That(Release(plan, "A").DependencyIds).IsEmpty();
+ await Assert.That(Release(plan, "B").DependencyIds).IsEquivalentTo(["C"]);
+ await Assert.That(plan.ReferencedPackages.Single()).IsEqualTo(new ReferencedPackage("C", "src/C/C.csproj", "3.4.5"));
+ }
+ }
+
[Test]
public async Task ApplicationFixDoesNotReleaseItsDependenciesOrUnrelatedPackages()
{
diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs
index a26c824..f0ff83f 100644
--- a/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs
+++ b/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs
@@ -1,5 +1,6 @@
using PackageCascade.Internal.Planning.Projects;
using PackageCascade.Internal.Planning;
+using System.Xml.Linq;
namespace PackageCascade.Tests.Unit.Internal.Planning.Projects;
@@ -51,9 +52,43 @@ public async Task UsesExplicitPackageId()
public async Task CapturesDirectProjectReferences()
{
IReadOnlyList packages = new SolutionPackageDiscoverer().Discover(_solutionPath);
+ PackableProject package = packages.Single(package => package.ProjectPath == _explicitProject);
- await Assert.That(packages.Single(package => package.ProjectPath == _explicitProject).ReferencedProjectPaths)
- .IsEquivalentTo(new[] { _defaultProject });
+ using (Assert.Multiple())
+ {
+ await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]);
+ await Assert.That(package.PackageDependencyProjectPaths).IsEquivalentTo([_defaultProject]);
+ }
+ }
+
+ [Test]
+ public async Task PrivateReferenceRemainsInProjectGraphButNotPackageDependencies()
+ {
+ SetDefaultReferencePrivateAssets("build ; ALL");
+
+ PackableProject package = new SolutionPackageDiscoverer().Discover(_solutionPath)
+ .Single(project => project.ProjectPath == _explicitProject);
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]);
+ await Assert.That(package.PackageDependencyProjectPaths).IsEmpty();
+ }
+ }
+
+ [Test]
+ public async Task PrivateAssetsElementAlsoExcludesPackageDependency()
+ {
+ SetDefaultReferencePrivateAssets("ALL", asElement: true);
+
+ PackableProject package = new SolutionPackageDiscoverer().Discover(_solutionPath)
+ .Single(project => project.ProjectPath == _explicitProject);
+
+ using (Assert.Multiple())
+ {
+ await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]);
+ await Assert.That(package.PackageDependencyProjectPaths).IsEmpty();
+ }
}
[Test]
@@ -72,9 +107,13 @@ public async Task CapturesReferencesConditionedOnTargetFramework()
""");
IReadOnlyList packages = new SolutionPackageDiscoverer().Discover(_solutionPath);
+ PackableProject package = packages.Single(package => package.ProjectPath == _explicitProject);
- await Assert.That(packages.Single(package => package.ProjectPath == _explicitProject).ReferencedProjectPaths)
- .IsEquivalentTo(new[] { _defaultProject });
+ using (Assert.Multiple())
+ {
+ await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]);
+ await Assert.That(package.PackageDependencyProjectPaths).IsEquivalentTo([_defaultProject]);
+ }
}
[Test]
@@ -133,4 +172,21 @@ private string WriteProject(string name, string properties, params string[] refe
return projectPath;
}
+
+ private void SetDefaultReferencePrivateAssets(string value, bool asElement = false)
+ {
+ XDocument document = XDocument.Load(_explicitProject);
+ XElement reference = document.Descendants("ProjectReference")
+ .Single(item => (string?)item.Attribute("Include") == "../Default/Default.csproj");
+ if (asElement)
+ {
+ reference.Add(new XElement("PrivateAssets", value));
+ }
+ else
+ {
+ reference.SetAttributeValue("PrivateAssets", value);
+ }
+
+ document.Save(_explicitProject);
+ }
}
diff --git a/src/PackageCascade/Internal/Planning/PlanGenerator.cs b/src/PackageCascade/Internal/Planning/PlanGenerator.cs
index 3fe92f9..887c598 100644
--- a/src/PackageCascade/Internal/Planning/PlanGenerator.cs
+++ b/src/PackageCascade/Internal/Planning/PlanGenerator.cs
@@ -201,7 +201,9 @@ private static IReadOnlyList CreatePackages(
Path.GetRelativePath(repositoryPath, project.ProjectPath).Replace('\\', '/'),
version.ToNormalizedString(),
GetReasons(project, previousTag, directChanges[project], selected, graph),
- graph[project].Select(dependency => dependency.PackageId)
+ graph[project].Where(dependency => project.PackageDependencyProjectPaths.Contains(dependency.ProjectPath,
+ OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal))
+ .Select(dependency => dependency.PackageId)
.Order(StringComparer.Ordinal).ToArray()));
}
diff --git a/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs b/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs
index b2399dc..f1703dd 100644
--- a/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs
+++ b/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs
@@ -1,3 +1,6 @@
namespace PackageCascade.Internal.Planning.Projects;
-internal sealed record PackableProject(string ProjectPath, string PackageId, IReadOnlyList ReferencedProjectPaths);
+internal sealed record PackableProject(string ProjectPath, string PackageId, IReadOnlyList ReferencedProjectPaths)
+{
+ public IReadOnlyList PackageDependencyProjectPaths { get; init; } = ReferencedProjectPaths;
+}
diff --git a/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs b/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs
index 26701fa..30afbae 100644
--- a/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs
+++ b/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs
@@ -48,6 +48,7 @@ private static PackableProject CreatePackage(
{
string packageId = nodes[0].ProjectInstance.GetPropertyValue("PackageId");
HashSet referencedPaths = new(pathComparer);
+ HashSet packageDependencyPaths = new(pathComparer);
foreach (ProjectGraphNode node in nodes)
{
@@ -62,16 +63,41 @@ private static PackableProject CreatePackage(
throw new InvalidOperationException($"Project '{projectPath}' has different PackageIds across evaluations.");
}
- foreach (ProjectItemInstance reference in node.ProjectInstance.GetItems("ProjectReference"))
+ CollectReferences(node, projects, referencedPaths, packageDependencyPaths);
+ }
+
+ return new(projectPath, packageId, referencedPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray())
+ {
+ PackageDependencyProjectPaths = packageDependencyPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray(),
+ };
+ }
+
+ private static void CollectReferences(
+ ProjectGraphNode node,
+ IReadOnlyDictionary projects,
+ HashSet referencedPaths,
+ HashSet packageDependencyPaths)
+ {
+ foreach (ProjectItemInstance reference in node.ProjectInstance.GetItems("ProjectReference"))
+ {
+ string referencedPath = reference.GetMetadataValue("FullPath");
+ if (!projects.ContainsKey(referencedPath))
+ {
+ continue;
+ }
+
+ referencedPaths.Add(referencedPath);
+ if (!IsPrivateReference(reference))
{
- string referencedPath = reference.GetMetadataValue("FullPath");
- if (projects.ContainsKey(referencedPath))
- {
- referencedPaths.Add(referencedPath);
- }
+ packageDependencyPaths.Add(referencedPath);
}
}
+ }
- return new(projectPath, packageId, referencedPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray());
+ private static bool IsPrivateReference(ProjectItemInstance reference)
+ {
+ return reference.GetMetadataValue("PrivateAssets")
+ .Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
+ .Contains("all", StringComparer.OrdinalIgnoreCase);
}
}
diff --git a/src/PackageCascade/Public/Definition/PlannedRelease.cs b/src/PackageCascade/Public/Definition/PlannedRelease.cs
index 2cca4ec..879f22e 100644
--- a/src/PackageCascade/Public/Definition/PlannedRelease.cs
+++ b/src/PackageCascade/Public/Definition/PlannedRelease.cs
@@ -5,7 +5,7 @@ namespace PackageCascade.Public.Definition;
/// The project file path; relative paths are resolved from the repository root.
/// The proposed package version as a normalised string.
/// The commit, dependency, or initial-release reasons for this release.
-/// The package IDs of directly referenced packable projects, including unchanged packages.
+/// The package IDs expected as package dependencies in the manifest, including unchanged packages.
public sealed record PlannedRelease(
string PackageId,
string ProjectPath,