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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Project values moved out of client options.** `DotNetBuildSettings`, `PackageRepository`, `ReleaseSettings`, `DockerImages` and `TofuModule` are arguments now; `GitOptions` and `NuGetOptions` keep only what the environment sets.
- **Clients are scoped to the run.** Anything registered as a singleton that depends on one fails `Build`.
- **A resource detector reads the application's services**, not the run's.
- **`IGit` acts on the repository each call names.** Every method takes the repository first, and `InRepository` is gone.
- **A command with no directory of its own runs in the project root inside a run, and in the process's directory outside one.**

### Added

- **`WorkflowApplication.Run(workflow, job, arguments, directory, options)`**, for a host that chooses the job and its arguments itself, and `WorkflowApplication.Workflows`.
- **`JobCommand.Create` and a public `WorkflowFlags`**, for a host building its own command line from Ritten's commands.
- **`WorkflowRegistry.Add<T>()` builds the workflow from the application's services**, so it can take dependencies.
- **`services.AddGit()` and `services.AddCommandRunner()`** register the clients with any host's services, such as a background service's. Outside a run nothing is narrated.
- **The command runner logs what it runs through `ILogger`**: the executable, its directory and its exit code, never its arguments.

### Removed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in

`NuGet/` and the release-state half of `Releases/` (`ReleaseState`, `ReleaseLine`, `PackagePublication`, `ReleasableGate`) are the `Ritten.NuGet` package, laid out like `Ritten.Core` (root namespace `Ritten`, a folder per namespace) so the namespaces didn't move with them. What only preparing a release needs — `ReleaseKind`, `RequestedVersion`, `NuGetVersion.Next`, `PrepareVersion` — stays in the tool.

Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, the argument types its steps read, steps (in a `Steps/` subfolder), and a `WorkflowBuilderExtensions.cs` whose `Add*()` registers the client and its dry-run pairing, never a project's values. External processes (dotnet, git, gh) run through `Commands/ICommandRunner`.
Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, the argument types its steps read, steps (in a `Steps/` subfolder), and a `WorkflowBuilderExtensions.cs` whose `Add*()` registers the client and its dry-run pairing, never a project's values. External processes (dotnet, git, gh) run through `Commands/ICommandRunner`, in the run's project root unless a command names its own directory. `ICommandRunner` and `IGit` also register on a plain `IServiceCollection` (`services.AddCommandRunner()`, `services.AddGit()`) for hosts with no workflow run: there the narrative is a silent `IWorkflowLog`, which `Build` drops because an application always has the run's, and commands run in the process's directory. **Clients hold no repository or project of their own** — `IGit` takes the repository on every call, `IOpenTofu` its module — so one client serves every repository a host works with. Beside the narrative, `CommandRunner` logs each command through `ILogger` (executable, directory, exit code; never the arguments), so a host's own logging hears what ran without third-party loggers reaching the console.

`Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow, its job classes (`init`, `status`, `build`, `install`, `prepare`, `check`, `deploy`) and `DotNetToolArguments` — the `ritten.json` schema — with `PrepareDotNetToolArguments`/`InstallDotNetToolArguments` adding the options only those jobs take; `init` neither requires a project nor reports. `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/WorkflowApplicationBuilderExtensions.cs` composes them for `Program.cs`.

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
<PackageVersion Include="NuGet.Common" Version="7.9.0" />
Expand Down
17 changes: 15 additions & 2 deletions src/Ritten.Core/Commands/CommandRunner.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging;
using Ritten.Contracts.FileSystem;
using Ritten.Reporting;

namespace Ritten.Commands;

internal class CommandRunner(IWorkflowLog log, IFileSystem fileSystem) : ICommandRunner
/// <summary>
/// Runs commands, narrating them to the run's log and recording them as diagnostics.
/// </summary>
/// <param name="log">The run's narrative; silent outside a run.</param>
/// <param name="logger">Where diagnostics go, whatever the host does with them.</param>
/// <param name="fileSystem">The run's file system, whose project root a command runs in unless it names its own
/// directory; outside a run there is none, and commands run in the process's directory.</param>
internal class CommandRunner(IWorkflowLog log, ILogger<CommandRunner> logger, IFileSystem? fileSystem = null) : ICommandRunner
{
public async Task<CommandResult> Run(Command command, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -33,12 +41,13 @@ public async Task<CommandResult> Run(Command command, CancellationToken cancella

private async Task<CommandResult> Execute(Command command, CancellationToken cancellationToken)
{
var directory = Path.Combine(fileSystem?.ProjectRoot.AbsolutePath ?? Environment.CurrentDirectory, command.WorkingDirectory ?? string.Empty);
using var process = new Process();
process.EnableRaisingEvents = true;
process.StartInfo = new ProcessStartInfo
{
FileName = command.Path,
WorkingDirectory = Path.Combine(fileSystem.ProjectRoot.AbsolutePath, command.WorkingDirectory ?? string.Empty),
WorkingDirectory = directory,
RedirectStandardInput = command.StandardInput is not null,
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand Down Expand Up @@ -75,6 +84,8 @@ private async Task<CommandResult> Execute(Command command, CancellationToken can
log.Verbose($"Running `{command.Path} {string.Join(" ", command.Arguments)}`");
}

// The executable and where it ran, never the arguments: they can carry secrets.
logger.LogDebug("Running {Executable} in {Directory}.", Path.GetFileName(command.Path), directory);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
Expand Down Expand Up @@ -109,10 +120,12 @@ await Task.WhenAny(

var exitLogLevel = process.ExitCode == 0 ? WorkflowLogLevel.Verbose : WorkflowLogLevel.Detail;
log.Log(exitLogLevel, $"Exit code: {process.ExitCode}");
logger.LogDebug("{Executable} exited with code {ExitCode}.", Path.GetFileName(command.Path), process.ExitCode);

var result = new CommandResult(process.ExitCode, stdOut.ToString(), stdErr.ToString());
if (command.ThrowsOnError && result.IsError)
{
logger.LogWarning("{Executable} failed with exit code {ExitCode}.", Path.GetFileName(command.Path), result.ExitCode.Value);
throw new CommandFailedException(FailureMessage(command, result), result);
}

Expand Down
25 changes: 25 additions & 0 deletions src/Ritten.Core/Commands/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Ritten.Reporting;

namespace Ritten.Commands;

/// <summary>
/// Registers the command runner with any host's services, inside a workflow application or not.
/// </summary>
public static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
/// <summary>
/// Adds the command runner, scoped to the unit of work it serves.
/// </summary>
public IServiceCollection AddCommandRunner()
{
services.AddLogging();
services.TryAddSingleton<IWorkflowLog, SilentWorkflowLog>();
services.TryAddScoped<ICommandRunner, CommandRunner>();
return services;
}
}
}
3 changes: 1 addition & 2 deletions src/Ritten.Core/Commands/WorkflowBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Ritten.Engine;

namespace Ritten.Commands;
Expand All @@ -15,7 +14,7 @@ public static class WorkflowBuilderExtensions
/// </summary>
public IWorkflowBuilder AddCommandRunner()
{
builder.Services.TryAddScoped<ICommandRunner, CommandRunner>();
builder.Services.AddCommandRunner();
return builder;
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/Ritten.Core/Engine/WorkflowApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ internal Result<WorkflowApplication> Build(Func<string, string?> environment)
var services = new ServiceCollection();
var decorators = new DecoratorRegistry().Decorate<IProjectFiles, DryRunProjectFiles>();
AddRunFacts(services, runtime.Value);
foreach (var service in Services)
// A client registered for any host brings a silent narrative for when there's no run; here there always is.
foreach (var service in Services.Where(s => s.ImplementationType != typeof(SilentWorkflowLog)))
{
services.Add(service);
}
Expand Down Expand Up @@ -118,6 +119,7 @@ private static Result<WorkflowApplication> Fail(List<Error> errors)
private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult runtime)
{
services.AddOptions();
services.AddLogging();
services.AddSingleton(TimeProvider.System);
services.AddSingleton(new WorkflowEnvironment(runtime.Environment));
services.AddScoped<RunState>();
Expand All @@ -127,6 +129,10 @@ private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult
services.AddScoped(provider => Current(provider).Job.Steps);
services.AddScoped(provider => Current(provider).Console);
services.AddScoped<IWorkflowProgress>(provider => provider.GetRequiredService<IWorkflowConsole>());

// Ahead of the host's services, so a client registered for any host finds the run's narrative already
// there; a host that registers its own still wins.
services.AddScoped<IWorkflowLog>(provider => provider.GetRequiredService<IWorkflowConsole>());
services.AddScoped(provider =>
{
var run = Current(provider);
Expand All @@ -139,7 +145,6 @@ private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult
/// </summary>
private static void AddDefaults(IServiceCollection services)
{
services.TryAddScoped<IWorkflowLog>(provider => provider.GetRequiredService<IWorkflowConsole>());
services.TryAddScoped<IWorkflowRunner, DefaultWorkflowRunner>();
services.TryAddScoped<IFileSystem, ProjectFileSystem>();
services.TryAddScoped<IProjectFiles, ProjectFileClient>();
Expand Down
15 changes: 15 additions & 0 deletions src/Ritten.Core/Reporting/SilentWorkflowLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Ritten.Reporting;

/// <summary>
/// The narrative outside a run, where nobody is watching one.
/// </summary>
internal sealed class SilentWorkflowLog : IWorkflowLog
{
/// <inheritdoc />
public bool IsEnabled(WorkflowLogLevel level) => false;

/// <inheritdoc />
public void Log(WorkflowLogLevel level, string? message, Exception? exception = null)
{
}
}
1 change: 1 addition & 0 deletions src/Ritten.Core/Ritten.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="all" />
<PackageReference Include="Spectre.Console" />
Expand Down
6 changes: 4 additions & 2 deletions src/Ritten.DotNet/Steps/ResolveRelease.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ritten.Contracts;
using Ritten.Contracts.FileSystem;
using Ritten.Git;
using Ritten.Reporting;

Expand All @@ -9,8 +10,9 @@ namespace Ritten.DotNet.Steps;
/// </summary>
/// <param name="log">The workflow log.</param>
/// <param name="git">The git client.</param>
/// <param name="fileSystem">The file system, whose project root is the repository asked.</param>
[Step("resolve release", StepKind.Work)]
public class ResolveRelease(IWorkflowLog log, IGit git)
public class ResolveRelease(IWorkflowLog log, IGit git, IFileSystem fileSystem)
{
/// <summary>
/// Derives the release's identity from the already-read projects.
Expand All @@ -31,7 +33,7 @@ public async Task<StepResult<Project>> Run(PackageRepository? declared, PackageS
// wins, then the project file's RepositoryUrl, then the origin remote.
var repository = declared?.Url
?? release.Repository
?? RepositoryUrls.ToWebUrl(await git.GetRemoteUrl("origin", cancellationToken));
?? RepositoryUrls.ToWebUrl(await git.GetRemoteUrl(fileSystem.ProjectRoot, "origin", cancellationToken));

log.Detail($"Releasing as {release.Name} (v{release.Version}).");
log.Verbose($"Repository: {repository ?? "unknown"}.");
Expand Down
Loading
Loading