Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <directory>`, which reads a plan from standard input or `--infile <path>`, 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

Expand Down
57 changes: 57 additions & 0 deletions src/PackageCascade.Cli/Commands/ApplyCommand.cs
Original file line number Diff line number Diff line change
@@ -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<string> output = new("--output")
{
Description = "Directory for generated package archives",
Required = true,
};

Option<string> inFile = new("--infile")
{
Description = "Read the plan from a file instead of standard input",
};

Option<string> repository = new("--repository")
{
Description = "Path to the Git repository (defaults to the current directory)",
};

Option<bool> 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<ReleasePlan>(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);
});
}
}
137 changes: 137 additions & 0 deletions src/PackageCascade.Tests.Integration/CliEndToEnd/ApplyProcessTests.cs
Original file line number Diff line number Diff line change
@@ -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"),
"<configuration><packageSources><clear /></packageSources></configuration>");
File.WriteAllText(Path.Combine(_directory, "Directory.Build.props"),
"<Project><PropertyGroup><NuGetAudit>false</NuGetAudit></PropertyGroup></Project>");
File.WriteAllText(_solutionPath, "<Solution><Project Path=\"Fixture/Fixture.csproj\" /></Solution>");
string projectDirectory = Directory.CreateDirectory(Path.Combine(_directory, "Fixture")).FullName;
File.WriteAllText(Path.Combine(projectDirectory, "Fixture.csproj"), """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net11.0</TargetFramework><DebugType>portable</DebugType></PropertyGroup>
</Project>
""");
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<string> output = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> 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);
}
}
114 changes: 114 additions & 0 deletions src/PackageCascade.Tests.Integration/CliOnly/ApplyCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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<IPackageCascadeExecutor>(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<IPackageCascadeExecutor>(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<IPackageCascadeExecutor>(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<IPackageCascadeExecutor>(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<string> 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<ExecutionResult> Execute(ReleasePlan plan, string repositoryPath,
ExecutionOptions options, CancellationToken cancellationToken = default)
{
Plan = plan;
RepositoryPath = repositoryPath;
Options = options;
CallCount++;
return Task.FromResult(new ExecutionResult([], []));
}
}
}
Loading