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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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.11.0] - 2026-08-27

### Added

- Added shell auto-completion support through the `completion` command.

### Fixed

- **`prepare` no longer describes one version twice.** A version whose packages had shipped in part was read as one that had never shipped at all, so `prepare` staged it a second time.
- **Notes written after a `prepare` join the entry they belong to.** Preparing a version the changelog already has an entry for now adds the unreleased notes to that entry rather than giving the version a second heading.

## [0.10.0] - 2026-08-23

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

Initial release.

[0.11.0]: https://github.com/ritten-org/Ritten/compare/v0.10.0...v0.11.0
[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
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.10.0</Version>
<Version>0.11.0</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<PackageIcon>icon.png</PackageIcon>
Expand Down
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.57.2" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.28.0" />
<PackageVersion Include="Verify.XunitV3" Version="32.0.0" />
<PackageVersion Include="Wolfe.CommandLine" Version="0.2.0" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
</Project>
47 changes: 47 additions & 0 deletions src/Ritten/Changelogs/ChangelogEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ public record ChangelogEntry
/// </summary>
public IReadOnlyCollection<string> Security { get; init; } = [];

/// <summary>
/// Takes on another entry's notes, keeping this entry's own first.
/// </summary>
/// <param name="other">The entry whose notes are being taken on.</param>
public ChangelogEntry Merge(ChangelogEntry other)
{
var merged = this with
{
Preamble = Join(Preamble, other.Preamble),
Added = [.. Added, .. other.Added],
Changed = [.. Changed, .. other.Changed],
Deprecated = [.. Deprecated, .. other.Deprecated],
Removed = [.. Removed, .. other.Removed],
Fixed = [.. Fixed, .. other.Fixed],
Security = [.. Security, .. other.Security]
};

// Where the sections account for both bodies in full, dropping the bodies lets the entry
// render as one set of sections rather than two — the same fixes under a single "Fixed".
// Where they don't, the body is the only view that holds a heading the format doesn't
// define, so the bodies are joined verbatim and an untidy repeat is the lesser loss.
return IsStructured && other.IsStructured
? merged with { Body = "" }
: merged with { Body = Join(Body, other.Body) };
}

/// <summary>
/// What these notes do to what already shipped.
/// </summary>
Expand All @@ -81,4 +107,25 @@ public record ChangelogEntry
&& Security.Count == 0
&& string.IsNullOrWhiteSpace(Preamble)
&& string.IsNullOrWhiteSpace(Body);

/// <summary>
/// Whether the sections account for the whole body, so nothing is lost by rebuilding it from them.
/// </summary>
/// <remarks>
/// Compared line by line rather than as text, because the two differences a rebuild does make —
/// the order the author wrote their sections in, and the blank lines between them — are
/// formatting the format itself prescribes. A heading the structured view can't hold, and the
/// notes under it, are lines that go missing, which is the loss this is looking for.
/// </remarks>
private bool IsStructured => Lines(ChangelogRenderer.RenderEntry(this with { Body = "" })).SetEquals(Lines(Body));

private static HashSet<string> Lines(string text) =>
[.. text.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0)];

private static string Join(string first, string second) => (first.Trim('\n'), second.Trim('\n')) switch
{
("", var only) => only,
(var only, "") => only,
var (a, b) => $"{a}\n\n{b}"
};
}
2 changes: 1 addition & 1 deletion src/Ritten/Changelogs/Steps/DecideVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task<StepResult<PreparedRelease>> Run(Project project, Changelog ch

// An unpublished version is already the next one: the project was bumped and never shipped,
// so preparing again would skip a version nobody released.
if (!release.Published)
if (!release.AnyPublished)
{
log.Detail($"Preparing {project.Version}, which the project already declares and hasn't published.");
return new PreparedRelease(project.Version, false, "already declared, not yet published");
Expand Down
24 changes: 17 additions & 7 deletions src/Ritten/Changelogs/Steps/PrepareChangelog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ TimeProvider time
/// <param name="ct">A token to monitor for cancellation requests.</param>
public async Task<StepResult> Run(Changelog changelog, Project project, PreparedRelease release, CancellationToken ct = default)
{
var hasChangelog = changelog.Entry(release.Version) is not null;
var rolled = WithRelease(changelog, release, out var entry);
var linked = WithLinks(rolled, project);

Expand All @@ -54,15 +55,17 @@ public async Task<StepResult> Run(Changelog changelog, Project project, Prepared
log.Detail(entry switch
{
null => $"Updated the version links in {options.Value.File}.",
_ when hasChangelog => $"Added the unreleased notes to the entry {release.Version} already had in {options.Value.File}.",
_ => $"Rolled the unreleased notes into {release.Version} in {options.Value.File}."
});

return StepResult.Successful;
}

/// <summary>
/// Dates the unreleased entry and gives it its version, leaving every other entry alone.
/// The body renders verbatim, so nobody's prose is reformatted on the way through.
/// Dates the unreleased entry and gives it its version — joining the entry that version already
/// has, when it has one — and leaves every other entry alone. The body renders verbatim, so
/// nobody's prose is reformatted on the way through.
/// </summary>
private Changelog WithRelease(Changelog changelog, PreparedRelease release, out ChangelogEntry? rolled)
{
Expand All @@ -80,13 +83,20 @@ private Changelog WithRelease(Changelog changelog, PreparedRelease release, out
return changelog;
}

rolled = unreleased with
var today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime);
var entries = changelog.Entries.ToList();

// The version may already have an entry: prepared once before and not yet shipped.
// Its notes join the ones already under that heading.
if (changelog.Entry(release.Version) is { } existing)
{
Version = release.Version,
Date = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime)
};
rolled = existing.Merge(unreleased) with { Date = today };
entries[entries.IndexOf(existing)] = rolled;
entries.Remove(unreleased);
return changelog with { Entries = entries };
}

var entries = changelog.Entries.ToList();
rolled = unreleased with { Version = release.Version, Date = today };
entries[entries.IndexOf(unreleased)] = rolled;
return changelog with { Entries = entries };
}
Expand Down
6 changes: 5 additions & 1 deletion src/Ritten/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using Ritten.Workflows.DotNet;
using Ritten.Workflows.DotNetPackage;
using Ritten.Workflows.DotNetTool;
using Wolfe.CommandLine;
using Wolfe.CommandLine.Completions;

var builder = WorkflowApplication.CreateBuilder();

Expand All @@ -23,7 +25,9 @@
return ExitCode.ConfigurationError;
}

var root = new RootCommand("The Ritten build workflow.");
var root = new RootCommand("The Ritten build workflow.")
.AddCompletions("ritten");
await root.InstallRitten(built.Value);
await CompletionAutoInstall.Run("ritten", args);

return await root.Parse(args).InvokeAsync();
5 changes: 5 additions & 0 deletions src/Ritten/Releases/ReleaseState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public sealed record ReleaseState(bool Published, bool LatestInLine, NuGetVersio
/// </summary>
public bool OnLatestLine => LatestVersionInLine == LatestVersion;

/// <summary>
/// Whether any one package in the build has this version on the feed.
/// </summary>
public bool AnyPublished => Published || Packages.Any(p => p.Published);

/// <summary>
/// Where each shipped package stands individually.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/Ritten/Ritten.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageReference Include="NuGet.Versioning" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Wolfe.CommandLine" />
</ItemGroup>

</Project>
40 changes: 40 additions & 0 deletions tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,44 @@ public void EmptyNotesReleaseNothing()
{
new ChangelogEntry().ReleaseKind.ShouldBe(ReleaseKind.None);
}

[Fact]
public void MergingGathersTheNotesUnderOneSetOfSections()
{
var existing = ChangelogParser.ParseEntry("### Added\n\n- A shipped thing.\n\n### Fixed\n\n- An old fix.");
var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.\n\n### Added\n\n- A later thing.");

var merged = existing.Merge(later);

merged.Added.ShouldBe(["A shipped thing.", "A later thing."]);
merged.Fixed.ShouldBe(["An old fix.", "A later fix."]);

// The sections held every line of both bodies, so the entry renders as one set of them —
// the order the author wrote their own sections in is not a reason to keep two.
ChangelogRenderer.RenderEntry(merged).ShouldBe("### Added\n\n- A shipped thing.\n- A later thing.\n\n### Fixed\n\n- An old fix.\n- A later fix.");
}

[Fact]
public void MergingKeepsBothBodiesWhenTheSectionsCannotHoldThem()
{
// "Notes" is not one of the six, so it lives on the body alone: rebuilding from the
// sections would drop the heading and everything under it.
var existing = ChangelogParser.ParseEntry("### Notes\n\n- Something the format has no section for.");
var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.");

var rendered = ChangelogRenderer.RenderEntry(existing.Merge(later));

rendered.ShouldContain("### Notes");
rendered.ShouldContain("- Something the format has no section for.");
rendered.ShouldContain("- A later fix.");
}

[Fact]
public void MergingIntoAnEntryWithNothingInItKeepsTheNotesArriving()
{
var merged = new ChangelogEntry().Merge(ChangelogParser.ParseEntry("### Fixed\n\n- A later fix."));

merged.Fixed.ShouldBe(["A later fix."]);
ChangelogRenderer.RenderEntry(merged).ShouldBe("### Fixed\n\n- A later fix.");
}
}
22 changes: 21 additions & 1 deletion tests/Ritten.Tests/Changelogs/DecideVersionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public async Task KeepsAVersionThatIsDeclaredButNotPublished()
await _prompt.DidNotReceive().Confirm(Arg.Any<string>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task MovesPastAVersionThatShippedInPart()
{
// One package of 1.2.0 reached the feed and another didn't: the version is out in the
// world, so what's left of it is deploy's to finish and prepare must move past it.
var result = await Step().Run(Project("1.2.0"), Changelog(new ChangelogEntry { Fixed = ["A thing."] }), PartlyPublished(), TestContext.Current.CancellationToken);

result.Value.ShouldNotBeNull().Version.ShouldBe(NuGetVersion.Parse("1.2.1"));
result.Value.Bumped.ShouldBeTrue();
}

[Fact]
public async Task DerivesFromTheUnreleasedNotesAndConfirms()
{
Expand Down Expand Up @@ -87,7 +98,16 @@ private static Changelog Changelog(ChangelogEntry? unreleased = null) =>
new() { Entries = unreleased is null ? [] : [unreleased] };

private static ReleaseState Published() =>
new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"));
new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"))
{
Packages = [new PackagePublication("My.Package", true)]
};

private static ReleaseState PartlyPublished() =>
new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"))
{
Packages = [new PackagePublication("My.Package", true), new PackagePublication("My.Package.Core", false)]
};

private static ReleaseState Unpublished() =>
new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"));
Expand Down
19 changes: 19 additions & 0 deletions tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
Expand Down Expand Up @@ -101,6 +102,24 @@ public async Task WritesNothingWhenTheChangelogAlreadySaysIt()
file.DidNotReceive().OpenWrite();
}

[Fact]
public async Task MergesIntoTheEntryTheVersionAlreadyHas()
{
// The version was prepared before and never shipped, so notes written since belong in the
// entry it already has: a second heading for the same version describes it twice.
SetChangelog(Existing);

var result = await Step().Run(Changelog(Existing), Project(), Prepared("1.2.0", bumped: false), TestContext.Current.CancellationToken);

result.IsFailure.ShouldBeFalse();
var written = Written();
Regex.Matches(written, "^## \\[1\\.2\\.0\\]", RegexOptions.Multiline).Count.ShouldBe(1);
written.ShouldContain("## [1.2.0] - 2026-08-21");
written.ShouldContain("- **A new thing.** It does something.");
written.ShouldContain("- **An old thing.** It was broken.");
written.ShouldNotContain("## [Unreleased]");
}

[Fact]
public async Task LeavesTheEntriesAloneWhenThereIsNothingUnreleased()
{
Expand Down