diff --git a/README.md b/README.md index c37c599..b97dfba 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ PackageCascade is a .NET tool for planning releases in solutions containing mult > [!WARNING] > We developed this library for our own internal use and are sharing it because we believe it may be useful to the wider developer community. Issues and pull requests are welcome, but we make no support commitments or warranties, and we cannot guarantee that we will respond to issues or review or merge pull requests. -The `plan` command is read-only. It writes the release plan to standard output as JSON, or to a file when given `--outfile`. Execution is separate: callers can use the plan themselves, and dedicated execution commands are planned. +The `plan` command is read-only. It writes the release plan to standard output as JSON, or to a file when given `--outfile`. Callers can execute the plan themselves or use `apply --output `, which reads a plan from standard input or `--infile `, builds and verifies its packages, and creates local Git tags. Use `--include-symbols` to include symbol packages. `apply` writes its result as JSON and does not publish packages or push tags. ## Work locally diff --git a/src/PackageCascade.Cli/Commands/ApplyCommand.cs b/src/PackageCascade.Cli/Commands/ApplyCommand.cs new file mode 100644 index 0000000..aeb009b --- /dev/null +++ b/src/PackageCascade.Cli/Commands/ApplyCommand.cs @@ -0,0 +1,57 @@ +using System.CommandLine; +using System.Text.Json; +using JetBrains.Annotations; +using PackageCascade.Cli.Serialization; +using PackageCascade.Public.Definition; +using PackageCascade.Public.Executing; + +namespace PackageCascade.Cli.Commands; + +[UsedImplicitly] +public sealed class ApplyCommand : Command +{ + public ApplyCommand(IPackageCascadeExecutor executor) : base("apply", "Apply a prepared release plan locally") + { + Option output = new("--output") + { + Description = "Directory for generated package archives", + Required = true, + }; + + Option inFile = new("--infile") + { + Description = "Read the plan from a file instead of standard input", + }; + + Option repository = new("--repository") + { + Description = "Path to the Git repository (defaults to the current directory)", + }; + + Option includeSymbols = new("--include-symbols") + { + Description = "Generate symbol packages", + }; + + Options.Add(output); + Options.Add(inFile); + Options.Add(repository); + Options.Add(includeSymbols); + + SetAction(async (parseResult, cancellationToken) => + { + string? inputPath = parseResult.GetValue(inFile); + string json = inputPath is null + ? await Console.In.ReadToEndAsync(cancellationToken) + : await File.ReadAllTextAsync(inputPath, cancellationToken); + ReleasePlan plan = JsonSerializer.Deserialize(json, PlanJsonOptions.Options) + ?? throw new JsonException("The release plan is null."); + + string repositoryPath = parseResult.GetValue(repository) ?? Directory.GetCurrentDirectory(); + ExecutionOptions options = new(parseResult.GetRequiredValue(output), parseResult.GetValue(includeSymbols)); + ExecutionResult result = await executor.Execute(plan, repositoryPath, options, cancellationToken); + string resultJson = JsonSerializer.Serialize(result); + await parseResult.InvocationConfiguration.Output.WriteLineAsync(resultJson.AsMemory(), cancellationToken); + }); + } +} diff --git a/src/PackageCascade.Tests.Integration/CliEndToEnd/ApplyProcessTests.cs b/src/PackageCascade.Tests.Integration/CliEndToEnd/ApplyProcessTests.cs new file mode 100644 index 0000000..a772e82 --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CliEndToEnd/ApplyProcessTests.cs @@ -0,0 +1,137 @@ +using System.Diagnostics; +using System.Text.Json; +using LibGit2Sharp; +using PackageCascade.Cli; + +namespace PackageCascade.Tests.Integration.CliEndToEnd; + +public sealed class ApplyProcessTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-cli-apply-").FullName; + private readonly Repository _repository; + private readonly string _solutionPath; + + public ApplyProcessTests() + { + Repository.Init(_directory); + _repository = new(_directory); + _solutionPath = Path.Combine(_directory, "Packages.slnx"); + + 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 projectDirectory = Directory.CreateDirectory(Path.Combine(_directory, "Fixture")).FullName; + File.WriteAllText(Path.Combine(projectDirectory, "Fixture.csproj"), """ + + net11.0portable + + """); + File.WriteAllText(Path.Combine(projectDirectory, "Api.cs"), + "public static class Fixture { public static int Value => 42; }\n"); + + foreach (string path in new[] { ".gitignore", "global.json", "NuGet.Config", "Directory.Build.props", + "Packages.slnx", "Fixture/Fixture.csproj", "Fixture/Api.cs" }) + { + Commands.Stage(_repository, path); + } + + Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); + _repository.Commit("chore: add package", author, author); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task BuiltCliAppliesGeneratedPlanFromFileOrStandardInput(bool useStandardInput, + CancellationToken cancellationToken) + { + string planPath = Path.Combine(_directory, "plan.json"); + string outputPath = Path.Combine(_directory, "packages"); + (int planExitCode, string planOutput, string planError) = useStandardInput + ? await Run(null, cancellationToken, "plan", _solutionPath, "--repository", _directory) + : await Run(null, cancellationToken, "plan", _solutionPath, "--repository", _directory, + "--outfile", planPath); + await Assert.That(planExitCode).IsEqualTo(0).Because(planError); + + string[] applyArguments = useStandardInput + ? ["apply", "--repository", _directory, "--output", outputPath, "--include-symbols"] + : ["apply", "--repository", _directory, "--infile", planPath, "--output", outputPath, + "--include-symbols"]; + (int applyExitCode, string output, string applyError) = await Run( + useStandardInput ? planOutput : null, cancellationToken, applyArguments); + await Assert.That(applyExitCode).IsEqualTo(0).Because(applyError); + + 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, "Fixture.0.1.0.nupkg"), + Path.Combine(outputPath, "Fixture.0.1.0.snupkg"), + ]; + + using (Assert.Multiple()) + { + await Assert.That(packagePaths).IsEquivalentTo(expectedPaths); + await Assert.That(expectedPaths.All(File.Exists)).IsTrue(); + await Assert.That(createdTags).IsEquivalentTo(new[] { "Fixture-v0.1.0" }); + await Assert.That(_repository.Tags["Fixture-v0.1.0"]?.Target.Sha) + .IsEqualTo(_repository.Head.Tip!.Sha); + await Assert.That(File.Exists(planPath)).IsEqualTo(!useStandardInput); + } + } + + private async Task<(int ExitCode, string Output, string Error)> Run( + string? standardInput, CancellationToken cancellationToken, params string[] arguments) + { + ProcessStartInfo startInfo = new("dotnet") + { + WorkingDirectory = _directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + }; + startInfo.ArgumentList.Add(typeof(Program).Assembly.Location); + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo)!; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(cancellationToken); + return (process.ExitCode, await output, await error); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + } + + public void Dispose() + { + _repository.Dispose(); + Directory.Delete(_directory, recursive: true); + } +} diff --git a/src/PackageCascade.Tests.Integration/CliOnly/ApplyCommandTests.cs b/src/PackageCascade.Tests.Integration/CliOnly/ApplyCommandTests.cs new file mode 100644 index 0000000..cb59a88 --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CliOnly/ApplyCommandTests.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PackageCascade.Cli; +using PackageCascade.Public.Definition; +using PackageCascade.Public.Executing; + +namespace PackageCascade.Tests.Integration.CliOnly; + +public sealed class ApplyCommandTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-apply-cli-").FullName; + + [Test] + public async Task ApplyPassesPreparedPlanAndRequestedOptionsToExecutor(CancellationToken cancellationToken) + { + string planPath = await WritePlan(new([], "Staging", [], "src"), cancellationToken); + string outputPath = Path.Combine(_directory, "packages"); + StubExecutor executor = new(); + TestBootstrap bootstrap = new(services => services.AddSingleton(executor)); + + int exitCode = await Program.Run([ + "apply", "--output", outputPath, "--infile", planPath, + "--repository", _directory, "--include-symbols", + ], bootstrap); + + using (Assert.Multiple()) + { + await Assert.That(exitCode).IsEqualTo(0); + await Assert.That(executor.Plan?.Configuration).IsEqualTo("Staging"); + await Assert.That(executor.Plan?.WorkingDirectory).IsEqualTo("src"); + await Assert.That(executor.RepositoryPath).IsEqualTo(_directory); + await Assert.That(executor.Options).IsEqualTo(new ExecutionOptions(outputPath, true)); + await Assert.That(executor.CallCount).IsEqualTo(1); + } + } + + [Test] + public async Task ApplyDefaultsRepositoryAndSymbolsWithoutChangingPlanConfiguration(CancellationToken cancellationToken) + { + string planPath = await WritePlan(new([], "Debug", [], "."), cancellationToken); + StubExecutor executor = new(); + TestBootstrap bootstrap = new(services => services.AddSingleton(executor)); + + int exitCode = await Program.Run([ + "apply", "--output", Path.Combine(_directory, "packages"), "--infile", planPath, + ], bootstrap); + + using (Assert.Multiple()) + { + await Assert.That(exitCode).IsEqualTo(0); + await Assert.That(executor.RepositoryPath).IsEqualTo(Directory.GetCurrentDirectory()); + await Assert.That(executor.Plan?.Configuration).IsEqualTo("Debug"); + await Assert.That(executor.Options?.IncludeSymbols).IsFalse(); + await Assert.That(executor.CallCount).IsEqualTo(1); + } + } + + [Test] + public async Task ApplyRequiresOutputDirectoryBeforeExecuting(CancellationToken cancellationToken) + { + string planPath = await WritePlan(new([], "Release", [], "."), cancellationToken); + StubExecutor executor = new(); + TestBootstrap bootstrap = new(services => services.AddSingleton(executor)); + + int exitCode = await Program.Run(["apply", "--infile", planPath], bootstrap); + + using (Assert.Multiple()) + { + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(executor.CallCount).IsEqualTo(0); + } + } + + [Test] + public async Task ApplyRejectsMalformedPlanBeforeExecuting(CancellationToken cancellationToken) + { + string path = Path.Combine(_directory, "broken-plan.json"); + await File.WriteAllTextAsync(path, "{invalid json", cancellationToken); + StubExecutor executor = new(); + TestBootstrap bootstrap = new(services => services.AddSingleton(executor)); + + int exitCode = await Program.Run(["apply", "--output", Path.Combine(_directory, "packages"), "--infile", path], bootstrap); + await Assert.That(exitCode).IsNotEqualTo(0); + + await Assert.That(executor.CallCount).IsEqualTo(0); + } + + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private async Task WritePlan(ReleasePlan plan, CancellationToken cancellationToken) + { + string path = Path.Combine(_directory, "plan.json"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(plan), cancellationToken); + return path; + } + + private sealed class StubExecutor : IPackageCascadeExecutor + { + public ReleasePlan? Plan { get; private set; } + public string? RepositoryPath { get; private set; } + public ExecutionOptions? Options { get; private set; } + public int CallCount { get; private set; } + + public Task Execute(ReleasePlan plan, string repositoryPath, + ExecutionOptions options, CancellationToken cancellationToken = default) + { + Plan = plan; + RepositoryPath = repositoryPath; + Options = options; + CallCount++; + return Task.FromResult(new ExecutionResult([], [])); + } + } +} diff --git a/src/PackageCascade.Tests.Integration/CoreOnly/Executing/ExecutorIntegrationTests.cs b/src/PackageCascade.Tests.Integration/CoreOnly/Executing/ExecutorIntegrationTests.cs new file mode 100644 index 0000000..87872bb --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CoreOnly/Executing/ExecutorIntegrationTests.cs @@ -0,0 +1,199 @@ +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; +using LibGit2Sharp; +using Microsoft.Extensions.DependencyInjection; +using NuGet.Versioning; +using PackageCascade.Public.Definition; +using PackageCascade.Public.DependencyInjection; +using PackageCascade.Public.Executing; + +namespace PackageCascade.Tests.Integration.CoreOnly.Executing; + +public sealed class ExecutorIntegrationTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-executor-integration-").FullName; + private readonly string _outputDirectory; + private readonly Repository _repository; + + public ExecutorIntegrationTests() + { + Repository.Init(_directory); + _repository = new(_directory); + _outputDirectory = Path.Combine(_directory, "packages"); + + 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 + """); + WriteProject("B", ""); + WriteProject("A", """ + + Staging package + + + """); + File.WriteAllText(Path.Combine(_directory, "A", "Api.cs"), + "public static class A { public static int Value => B.Value; }\n"); + + foreach (string path in new[] { "global.json", "NuGet.Config", "Directory.Build.props", + "A/A.csproj", "A/Api.cs", "B/B.csproj", "B/Api.cs" }) + { + Commands.Stage(_repository, path); + } + + Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); + _repository.Commit("chore: add packages", author, author); + } + + [Test] + public async Task AppliesDependencyFirstPlanWithIndependentVersions(CancellationToken cancellationToken) + { + ExecutionResult result = await Execute(Plan(), includeSymbols: false, cancellationToken); + string[] expectedPaths = [ + Path.Combine(_outputDirectory, "B.3.4.5.nupkg"), + Path.Combine(_outputDirectory, "A.2.3.4.nupkg"), + ]; + using (Assert.Multiple()) + { + await Assert.That(result.PackagePaths).IsEquivalentTo(expectedPaths); + await Assert.That(expectedPaths.All(File.Exists)).IsTrue(); + await Assert.That(result.CreatedTags).IsEquivalentTo(new[] { "B-v3.4.5", "A-v2.3.4" }); + await Assert.That(_repository.Tags["B-v3.4.5"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha); + await Assert.That(_repository.Tags["A-v2.3.4"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha); + await AssertIdentity(await ReadMetadata(expectedPaths[0], cancellationToken), "B", "3.4.5"); + await AssertIdentity(await ReadMetadata(expectedPaths[1], cancellationToken), "A", "2.3.4"); + await AssertDependency(await ReadMetadata(expectedPaths[1], cancellationToken)); + } + } + + [Test] + public async Task AppliesReleaseAgainstUnchangedReferencedPackage(CancellationToken cancellationToken) + { + ExecutionResult result = await Execute(Plan(includeB: false), includeSymbols: false, cancellationToken); + string archivePath = Path.Combine(_outputDirectory, "A.2.3.4.nupkg"); + + using (Assert.Multiple()) + { + await Assert.That(result.PackagePaths).IsEquivalentTo(new[] { archivePath }); + await Assert.That(Directory.GetFiles(_outputDirectory, "*.nupkg")).IsEquivalentTo(new[] { archivePath }); + await Assert.That(result.CreatedTags).IsEquivalentTo(new[] { "A-v2.3.4" }); + await Assert.That(_repository.Tags["A-v2.3.4"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha); + await Assert.That(_repository.Tags["B-v3.4.5"]).IsNull(); + await AssertDependency(await ReadMetadata(archivePath, cancellationToken)); + } + } + + [Test] + public async Task UsesSelectedConfigurationAndProducesSymbols(CancellationToken cancellationToken) + { + ExecutionResult result = await Execute(Plan(configuration: "Staging"), includeSymbols: true, cancellationToken); + string[] expectedPaths = [ + Path.Combine(_outputDirectory, "B.3.4.5.nupkg"), + Path.Combine(_outputDirectory, "B.3.4.5.snupkg"), + Path.Combine(_outputDirectory, "A.2.3.4.nupkg"), + Path.Combine(_outputDirectory, "A.2.3.4.snupkg"), + ]; + XElement metadata = await ReadMetadata(expectedPaths[2], cancellationToken); + + using (Assert.Multiple()) + { + await Assert.That(result.PackagePaths).IsEquivalentTo(expectedPaths); + await Assert.That(expectedPaths.All(File.Exists)).IsTrue(); + await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "description").Value) + .IsEqualTo("Staging package"); + await AssertIdentity(await ReadMetadata(expectedPaths[1], cancellationToken), "B", "3.4.5"); + await AssertIdentity(await ReadMetadata(expectedPaths[3], cancellationToken), "A", "2.3.4"); + } + } + + [Test] + public async Task DoesNotTagWhenPackageIsMissingPlannedDependency(CancellationToken cancellationToken) + { + string projectPath = Path.Combine(_directory, "A", "A.csproj"); + XDocument project = XDocument.Parse(await File.ReadAllTextAsync(projectPath, cancellationToken)); + project.Root!.Add(new XElement("PropertyGroup", new XElement("SuppressDependenciesWhenPacking", "true"))); + await File.WriteAllTextAsync(projectPath, project.ToString(), cancellationToken); + + InvalidOperationException? error = await Assert.ThrowsAsync( + () => Execute(Plan(), includeSymbols: false, cancellationToken)); + + using (Assert.Multiple()) + { + await Assert.That(error!.Message).Contains("missing dependency 'B'"); + await Assert.That(_repository.Tags).IsEmpty(); + await Assert.That(Directory.Exists(_outputDirectory) && Directory.GetFiles(_outputDirectory).Length > 0).IsFalse(); + } + } + + public void Dispose() + { + _repository.Dispose(); + Directory.Delete(_directory, recursive: true); + } + + private static ReleasePlan Plan(bool includeB = true, string configuration = "Release") + { + PlannedRelease dependency = new("B", "B/B.csproj", "3.4.5", [new InitialReleaseReason()], []); + PlannedRelease consumer = new("A", "A/A.csproj", "2.3.4", [new DependencyReleaseReason("B")], ["B"]); + return includeB + ? new([dependency, consumer], configuration, [], ".") + : new([consumer], configuration, [new ReferencedPackage("B", "B/B.csproj", "3.4.5")], "."); + } + + private async Task Execute(ReleasePlan plan, bool includeSymbols, CancellationToken cancellationToken) + { + await using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadeExecutor executor = provider.GetRequiredService(); + return await executor.Execute(plan, _directory, + new ExecutionOptions(_outputDirectory, IncludeSymbols: includeSymbols), cancellationToken); + } + + private void WriteProject(string name, string references) + { + string directory = Directory.CreateDirectory(Path.Combine(_directory, name)).FullName; + File.WriteAllText(Path.Combine(directory, name + ".csproj"), $""" + + + net11.0 + portable + + {references} + + """); + File.WriteAllText(Path.Combine(directory, "Api.cs"), $"public static class {name} {{ public static int Value => 42; }}\n"); + } + + 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 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 AssertDependency(XElement metadata) + { + XElement dependency = metadata.Descendants().Single(element => element.Name.LocalName == "dependency"); + await Assert.That((string?)dependency.Attribute("id")).IsEqualTo("B"); + VersionRange range = VersionRange.Parse(dependency.Attribute("version")!.Value); + await Assert.That(range.MinVersion).IsEqualTo(NuGetVersion.Parse("3.4.5")); + await Assert.That(range.IsMinInclusive).IsTrue(); + } +}