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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.10.0] - 2026-08-23

### Added

- **The tool client works the way the SDK does.** `IDotNet`'s tool methods are one per `dotnet tool` verb — `ToolInstall`, `ToolUpdate`, `ToolUninstall`, and `InstalledToolVersion` for `list`.
- **The engine reads and writes project files.** `IProjectFiles` and `ProjectFile` load a `ritten.json` as a document, set values by key path, and write it back with everything else intact.
- **`Ritten.GitHub` maintains GitHub Actions workflows.** `IActionsWorkflows` and `ActionsWorkflow` read a workflow file, find its jobs by what they run, and write a job or a trigger back into it without disturbing a line of the rest.
- **`Ritten.DotNet` manages the tool manifest.** `IDotNet` gains `ToolUpdate` and `CreateToolManifest`, and `DotNetProjects` reads what a repository holds.
- **Jobs can declare that they run without a project.** `IJob.RequiresProject` is what lets a job create the project file.
- **Workflows can check compatibility.** `IWorkflow.IsCompatible` answers whether a directory looks like its kind, and `WorkflowRegistry.IsCompatible` asks each in registration order.
- **A file knows its directory, and a directory can place one.** `IFile.Directory` returns the directory a file is in, so anything writing a nested file can create the path first, and `IDirectory.RelativePath(file)`/`RelativePath(directory)` write a path the way a project file spells one.

### Changed

- **`ritten init` is now a job instead of a command.** It runs like every other job, with `--dry-run`, `--verbose` and the rest, and each workflow declares its own. A repository with no `ritten.json` yet has its workflow detected by what's in it, or `--workflow` sets one explicitly.
- **Resolving a repository is its own step.** `WorkflowApplication.SelectWorkflow` detects the workflow for a given directory.
- **Workflows can run without a project file.** `RittenProject.Resolve` answers with a synthetic project when nothing has been written yet.
- **Init ensures rather than scaffolds.** Every file it touches is loaded as a document, given whatever it's missing, and written back, so a changelog keeps its entries, a `ritten.json` keeps all its keys, a tool manifest keeps the other tools it pins, and an Actions workflow keeps its other jobs, triggers, and comments.
- **The Actions workflow is named for the project, and found by what it runs.** Ritten owns the jobs it wrote, not the file they live in: a renamed workflow file is updated in place rather than duplicated.

## [0.9.0] - 2026-08-22

### Added
Expand Down Expand Up @@ -189,6 +209,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

Initial release.

[0.10.0]: https://github.com/ritten-org/Ritten/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/ritten-org/Ritten/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/ritten-org/Ritten/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/ritten-org/Ritten/compare/v0.6.0...v0.7.0
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in

**Workflows and jobs are declarations, authored as object models; only steps are discovered by convention.** An `IWorkflow` (`Name` = the `ritten.json` identifier, `Label` = the printed human name) exposes stable job instances through its `Jobs` property. A job is a class extending `Job<TSettings>`, overriding `Name`, `Description` (the CLI is rendered from the model, so the help text lives with the job), `Steps` (an ordered list built with `Step.FromType<TStep>()`, which throws for a malformed step type — that's a programming error, not configuration), and as needed `Arguments`/`Configure(builder, settings, args)` (see below) or `Configure(builder, settings)` (an `IWorkflowConfiguration` — just `Services` and the `Decorators` registry, which is all the domain `Add*` extensions need; both builders implement it, so domain registrations compose at run level or application level alike, and a job or runtime can never reach the run controls of the builder that owns it) and `ValidateSettings(settings)` — the latter takes a `SettingsValidator<TSettings>` offering `Require(s => s.Build.Project)` (the error's `ritten.json` key derives from the property chain) and `RequireEnvironment("VAR")` (dry runs warn instead of failing). Validation and registration are deferred until settings exist, so a job's shape cannot depend on any project's configuration, and the whole registry→workflow→job→step tree is data at startup.

**Resolution comes before the run, and a job that sets a repository up runs before there is one to resolve.** `WorkflowApplication.Resolve(directory, workflow?)` answers what a directory asks Ritten to be and returns a `WorkflowSelection` — the `RittenProject` (found, or `Synthetic` because nothing has been written yet), the `IWorkflow`, and how it was chosen; `Run(selection, args)` takes that, so `RunJobArgs` describes only the job. `RittenProject.Resolve` treats a missing file as a state and only a broken one as a failure, and `GetWorkflowName` says which of the two the matter is. The workflow comes from the project's `"workflow"` key; failing that from the name the caller gave (refused, never ignored, when the project declares a different one); failing that from the workflows themselves — `IWorkflow.Recognise(repository)` answers *why* a repository looks like its kind (`src/My.Tool/My.Tool.csproj packs as a tool`), `WorkflowRegistry.Recognise` asks each in registration order and the first to recognise it wins, so a host registers its most specific workflow first. `IJob.RequiresProject` (default true) is then judged of the chosen job: a job that needs a declaration and hasn't got one is refused with `WorkflowSelection.Undeclared`, while `init` proceeds on default settings that nothing validates — it is what makes them valid. A step confirms a *recognised* workflow with the person before writing it down.

**Init ensures, it does not scaffold.** Each of its steps loads a file as a document, gives it whatever it lacks, and writes it back: `ritten.json` through the engine's `IProjectFiles`/`ProjectFile` (a `JsonNode` round-trip, so keys a newer tool wrote survive), `CHANGELOG.md` through `IChangelog`, the tool manifest through the SDK itself (`IDotNet.CreateToolManifest` then `ToolInstall`/`ToolUpdate` at `ToolScope.Local(root)` — the schema is Microsoft's, so a repository that pins other tools keeps them), and the GitHub Actions workflow through `Ritten.GitHub`'s `IActionsWorkflows`/`ActionsWorkflow`, which splices a job or a trigger into the document and leaves every other line — other jobs, comments, formatting — untouched. Nothing is compared byte for byte, so there is no drift to force: Ritten owns the jobs it wrote, found by *what they run* rather than by the file's name, and a repository of several projects gets one workflow file per project — named for the project it builds (the first declared, else the first found), never for the tool — each with its own working directory and concurrency group.

**A job declares what it can be asked for, the same way it declares what the repository tells it.** Settings come from `ritten.json`; *arguments* come from the invocation, and take the same path. `Arguments` lists `JobArgument` declarations — `JobArgument.Value<T>(name, description, read)` carries a domain reader (`string → Result<T>`, so a bad value is refused in the domain's own words) and `JobArgument.Flag(name, description)` is presence-only. `WorkflowApplication.Run` reads the supplied text into those types *before* anything is assembled, so an unknown name, an unreadable value, or a missing required one is a configuration error beside "unknown job" — never a step failure several steps in. The job then reads its values in `Configure(builder, settings, args)` through `args.Get(TheArgument)`/`args.IsSet(TheFlag)` — keyed by the declaration instance itself, so nothing is looked up by string or by type — and registers whatever domain value its steps consume (`RequestedVersion`, `ForceReinstall`). **Steps never see `JobArguments`, and never see a settings record either**: they depend on domain types, so a step stays reusable and the job stays free to source that value from anywhere. A scalar is never registered — `NuGetVersion` alone couldn't say whether it's the project's version, the feed's latest, or the one being asked for, and the type is what carries that.

`Program.cs` follows the familiar .NET hosting shape: `WorkflowApplication.CreateBuilder()` returns a `WorkflowApplicationBuilder` exposing `Workflows`, `Runtimes`, and `Services` (registrations shared by every job of every workflow; the runtime's and the job's own land later, so the more specific wins), and `builder.Build()` judges the whole registered model (duplicate names and the structural job rules — every registered job, every run) as the first validation exit, narrating failures itself and returning `Result<WorkflowApplication>`. The commands themselves are rendered from the model, not hardcoded, and by a *package* rather than the engine: `Ritten.CommandLine` (`application.CreateCommandLine(description)`) calls `application.ResolveJobs(directory)` — which resolves `ritten.json` and returns that workflow's jobs, falling back to every registered job when no project resolves so a broken configuration can still ask for help — and turns each into a command carrying its `Description` and an option per declared argument. Each `JobArgument` maps through `JobArgument.Map` and an `IJobArgumentMapper<TResult>`, which recovers the declaration's `T` so a `JobArgument<T>` becomes an `Option<T>` parsed by the domain's own reader; the engine references no CLI library, so System.CommandLine's churn stops at that package. A repository therefore only ever offers jobs its workflow can actually run. Each command then calls `application.Run(args, ct)` (`RunJobArgs` carries job/log-level/flags plus the read argument values — run-scoped things enter at `Run`, never through the builder; values are built through the declarations themselves, so one a job never declared can't be expressed, and the engine only checks that required ones are present). The engine's own flags are the ones true of every job — `--verbose`/`--quiet`, `--dry-run` (it drives the decorators) and `--auto-approve` (gate vocabulary), which is why they live on `WorkflowJob`; anything a single job honours is that job's argument instead (`--force` belongs to `install`). `Run` stages: detect the runtime and create the console narrative from it (`IWorkflowConsole` — the runtime's renderer at the flag-requested level, floored at Verbose when the runtime reports a debug request like `RUNNER_DEBUG`; errors before a runtime exists print through the engine's own Spectre renderer), resolve the project file (walking up from the cwd; `ritten.json` unless the host renames it via `builder.ProjectFileName`), select the workflow by the required `"workflow"` key, select the job *before* settings parse (a typo'd command needs no valid config to diagnose), then `WorkflowRunBuilder.Build(job)` assembles the one chosen job into a `WorkflowRun`: the job loads its settings as one operation (camelCase deserialization against the job's settings record — tolerant of unknown keys, so a project file can carry keys for newer tool versions without breaking a pinned one; every settings record extends `WorkflowSettings` — then `ValidateSettings` judges them, so invalid settings never leave the load), then services, steps, dry-run decoration, workflow-registered rules, and a DI container (`Microsoft.Extensions.DependencyInjection`, with `ValidateOnBuild`). `DefaultWorkflowRunner` executes the steps in order.
Expand All @@ -62,7 +66,7 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in

Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, options, steps (in a `Steps/` subfolder), and a `WorkflowConfigurationExtensions.cs` registering them against `IWorkflowConfiguration` (services and dry-run decorators together). External processes (dotnet, git, gh) run through `Commands/ICommandRunner`.

`Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow and its four job classes (`status`, `build`, `check`, `deploy`), which share the standard service registrations through the workflow-local `DotNetToolJob` base; `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/DotNetToolSettings.cs` and its siblings define the `ritten.json` schema.
`Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow and its job classes (`init`, `status`, `build`, `install`, `prepare`, `check`, `deploy`), which share the standard service registrations through the workflow-local `DotNetToolJob` base (`init` registers only what it needs, since it runs before there are settings to register from); `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/DotNetToolSettings.cs` and its siblings define the `ritten.json` schema.

**Reporting is two channels:** `IWorkflowLog` is the console narrative (rendered by `SpectreProgressReporter`), while `IBuildReport` accumulates a markdown report that `GitHubCommentSink` posts as the PR comment. Check steps typically write to both.

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<Copyright>Copyright © 2026 Tom Wolfe</Copyright>
<!-- One version for every package the repository ships: lockstep by construction here,
and `check` still guards it for repositories that keep per-project versions. -->
<Version>0.9.0</Version>
<Version>0.10.0</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<PackageIcon>icon.png</PackageIcon>
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.28.0" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>
25 changes: 23 additions & 2 deletions src/Ritten.CommandLine/CommandExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ public async Task InstallRitten(WorkflowApplication application, CancellationTok
}
}

/// <summary>
/// The option that names a workflow, for jobs that run without one.
/// </summary>
private static Option<string> WorkflowOption() => new($"--{WorkflowArguments.Workflow}")
{
Description = "The workflow to run. Recognised from what's in the project when omitted."
};

/// <summary>
/// Builds the command for a single job.
/// </summary>
Expand All @@ -44,7 +52,14 @@ private static Command JobCommand(IJob job, WorkflowFlags flags, WorkflowApplica
command.Options.Add(argument.Option);
}

command.SetAction(async (parseResult, cancellationToken) =>
// A job that runs without a project has no predetermined workflow.
var workflow = job.RequiresProject ? null : WorkflowOption();
if (workflow is not null)
{
command.Options.Add(workflow);
}

command.SetAction(async (parseResult, ct) =>
{
var builder = new JobArgumentsBuilder(parseResult);
foreach (var argument in arguments)
Expand All @@ -61,7 +76,13 @@ private static Command JobCommand(IJob job, WorkflowFlags flags, WorkflowApplica
Arguments = jobArgs
};

return await application.Run(args, cancellationToken);
var selection = await application.SelectWorkflow(
Environment.CurrentDirectory,
workflow is null ? null : parseResult.GetValue(workflow),
ct
);

return await application.Run(selection, args, ct);
});

return command;
Expand Down
25 changes: 25 additions & 0 deletions src/Ritten.Core/Contracts/FileSystem/DirectoryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Ritten.Contracts.FileSystem;

/// <summary>
/// Contains extension methods for <see cref="IDirectory"/>.
/// </summary>
public static class DirectoryExtensions
{
extension(IDirectory directory)
{
/// <summary>
/// The path of the given file relative to this directory.
/// </summary>
/// <param name="file">The file to write the path of.</param>
public string RelativePath(IFile file) => Relative(directory, file.AbsolutePath);

/// <summary>
/// The path of the given directory relative to this one.
/// </summary>
/// <param name="other">The directory to write the path of.</param>
public string RelativePath(IDirectory other) => Relative(directory, other.AbsolutePath);
}

private static string Relative(IDirectory directory, string path) =>
Path.GetRelativePath(directory.AbsolutePath, path).Replace(Path.DirectorySeparatorChar, '/');
}
5 changes: 5 additions & 0 deletions src/Ritten.Core/Contracts/FileSystem/IFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public interface IFile
/// </summary>
bool Exists { get; }

/// <summary>
/// Gets the directory the file is in, whether or not either exists yet.
/// </summary>
IDirectory Directory { get; }

/// <summary>
/// Deletes the file from the file system if it exists.
/// </summary>
Expand Down
28 changes: 28 additions & 0 deletions src/Ritten.Core/Engine/DryRunProjectFiles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Ritten.Contracts.FileSystem;
using Ritten.Reporting;

namespace Ritten.Engine;

/// <summary>
/// Reports what the project file would say instead of writing it.
/// </summary>
internal sealed class DryRunProjectFiles(IWorkflowLog log, IProjectFiles inner) : IProjectFiles
{
/// <inheritdoc />
public Task<Result<ProjectFile>> Read(IFile file, CancellationToken cancellationToken = default) =>
inner.Read(file, cancellationToken);

/// <inheritdoc />
public Task Write(IFile file, ProjectFile document, CancellationToken cancellationToken = default)
{
log.Skipped($"Would write {file.Name}:");
log.Verbose(inner.Render(document));
return Task.CompletedTask;
}

/// <inheritdoc />
public Result<ProjectFile> Parse(string json) => inner.Parse(json);

/// <inheritdoc />
public string Render(ProjectFile document) => inner.Render(document);
}
3 changes: 3 additions & 0 deletions src/Ritten.Core/Engine/FileSystem/PhysicalFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public class PhysicalFile(string path) : IFile
/// <inheritdoc />
public bool Exists => File.Exists(AbsolutePath);

/// <inheritdoc />
public IDirectory Directory => new PhysicalDirectory(Path.GetDirectoryName(AbsolutePath) ?? AbsolutePath);

/// <inheritdoc />
public void Delete()
{
Expand Down
Loading